diff --git a/src/Servers/HttpSys/src/RequestProcessing/RequestContext.FeatureCollection.cs b/src/Servers/HttpSys/src/RequestProcessing/RequestContext.FeatureCollection.cs index a66e44d1484c..f6448d5a921b 100644 --- a/src/Servers/HttpSys/src/RequestProcessing/RequestContext.FeatureCollection.cs +++ b/src/Servers/HttpSys/src/RequestProcessing/RequestContext.FeatureCollection.cs @@ -38,7 +38,8 @@ internal partial class RequestContext : IHttpResetFeature, IHttpSysRequestDelegationFeature, IHttpSysRequestPropertyFeature, - IConnectionLifetimeNotificationFeature + IConnectionLifetimeNotificationFeature, + IConnectionEndPointFeature { private IFeatureCollection? _features; private bool _enableResponseCaching; @@ -762,4 +763,46 @@ public bool TryGetTlsClientHello(Span tlsClientHelloBytesDestination, out { return TryGetTlsClientHelloMessageBytes(tlsClientHelloBytesDestination, out bytesReturned); } + + EndPoint? IConnectionEndPointFeature.LocalEndPoint + { + get + { + var localIp = ((IHttpConnectionFeature)this).LocalIpAddress; + if (localIp is not null) + { + return new IPEndPoint(localIp, ((IHttpConnectionFeature)this).LocalPort); + } + return null; + } + set + { + if (value is IPEndPoint localIPEndPoint) + { + ((IHttpConnectionFeature)this).LocalIpAddress = localIPEndPoint.Address; + ((IHttpConnectionFeature)this).LocalPort = localIPEndPoint.Port; + } + } + } + + EndPoint? IConnectionEndPointFeature.RemoteEndPoint + { + get + { + var remoteIp = ((IHttpConnectionFeature)this).RemoteIpAddress; + if (remoteIp is not null) + { + return new IPEndPoint(remoteIp, ((IHttpConnectionFeature)this).RemotePort); + } + return null; + } + set + { + if (value is IPEndPoint remoteIPEndPoint) + { + ((IHttpConnectionFeature)this).RemoteIpAddress = remoteIPEndPoint.Address; + ((IHttpConnectionFeature)this).RemotePort = remoteIPEndPoint.Port; + } + } + } } diff --git a/src/Servers/HttpSys/src/StandardFeatureCollection.cs b/src/Servers/HttpSys/src/StandardFeatureCollection.cs index 1c7d078d8253..30c08bc9d7f5 100644 --- a/src/Servers/HttpSys/src/StandardFeatureCollection.cs +++ b/src/Servers/HttpSys/src/StandardFeatureCollection.cs @@ -31,6 +31,7 @@ internal sealed class StandardFeatureCollection : IFeatureCollection { typeof(IHttpResponseTrailersFeature), ctx => ctx.GetResponseTrailersFeature() }, { typeof(IHttpResetFeature), ctx => ctx.GetResetFeature() }, { typeof(IConnectionLifetimeNotificationFeature), ctx => ctx.GetConnectionLifetimeNotificationFeature() }, + { typeof(IConnectionEndPointFeature), _identityFunc }, }; private readonly RequestContext _featureContext; diff --git a/src/Servers/HttpSys/test/FunctionalTests/ConnectionEndPointFeatureTests.cs b/src/Servers/HttpSys/test/FunctionalTests/ConnectionEndPointFeatureTests.cs new file mode 100644 index 000000000000..562bb7c67404 --- /dev/null +++ b/src/Servers/HttpSys/test/FunctionalTests/ConnectionEndPointFeatureTests.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.InternalTesting; +using Xunit; + +namespace Microsoft.AspNetCore.Server.HttpSys; + +public class ConnectionEndPointFeatureTests : LoggedTest +{ + [ConditionalFact] + public async Task Request_ProvidesConnectionEndPointFeature() + { + string root; + EndPoint localEndPoint = null; + EndPoint remoteEndPoint = null; + using (Utilities.CreateHttpServerReturnRoot("/", out root, httpContext => + { + try + { + var endPointFeature = httpContext.Features.Get(); + localEndPoint = endPointFeature.LocalEndPoint; + remoteEndPoint = endPointFeature.RemoteEndPoint; + } + catch (Exception ex) + { + byte[] body = Encoding.ASCII.GetBytes(ex.ToString()); + httpContext.Response.Body.Write(body, 0, body.Length); + } + return Task.FromResult(0); + }, options => { }, LoggerFactory)) + { + string response = await SendRequestAsync(root + "/"); + Assert.Equal(string.Empty, response); + } + + Assert.NotNull(localEndPoint); + Assert.NotNull(remoteEndPoint); + var localIPEndPoint = Assert.IsType(localEndPoint); + var remoteIPEndPoint = Assert.IsType(remoteEndPoint); + Assert.NotEqual(0, localIPEndPoint.Port); + Assert.NotEqual(0, remoteIPEndPoint.Port); + } + + private async Task SendRequestAsync(string uri) + { + using (var client = new System.Net.Http.HttpClient()) + { + return await client.GetStringAsync(uri); + } + } +} diff --git a/src/Servers/IIS/IIS/src/Core/IISHttpContext.FeatureCollection.cs b/src/Servers/IIS/IIS/src/Core/IISHttpContext.FeatureCollection.cs index 6e3475a5f1b8..c58e290b7f4f 100644 --- a/src/Servers/IIS/IIS/src/Core/IISHttpContext.FeatureCollection.cs +++ b/src/Servers/IIS/IIS/src/Core/IISHttpContext.FeatureCollection.cs @@ -4,6 +4,7 @@ using System.Collections; using System.Diagnostics; using System.IO.Pipelines; +using System.Net; using System.Net.Security; using System.Runtime.InteropServices; using System.Security.Authentication; @@ -37,6 +38,7 @@ internal partial class IISHttpContext : IFeatureCollection, IHttpResponseTrailersFeature, IHttpResetFeature, IConnectionLifetimeNotificationFeature, + IConnectionEndPointFeature, IHttpSysRequestInfoFeature, IHttpSysRequestTimingFeature { @@ -522,4 +524,46 @@ void IConnectionLifetimeNotificationFeature.RequestClose() ResponseHeaders.Connection = ConnectionClose; } } + + EndPoint? IConnectionEndPointFeature.LocalEndPoint + { + get + { + var localIp = ((IHttpConnectionFeature)this).LocalIpAddress; + if (localIp is not null) + { + return new IPEndPoint(localIp, ((IHttpConnectionFeature)this).LocalPort); + } + return null; + } + set + { + if (value is IPEndPoint localIPEndPoint) + { + ((IHttpConnectionFeature)this).LocalIpAddress = localIPEndPoint.Address; + ((IHttpConnectionFeature)this).LocalPort = localIPEndPoint.Port; + } + } + } + + EndPoint? IConnectionEndPointFeature.RemoteEndPoint + { + get + { + var remoteIp = ((IHttpConnectionFeature)this).RemoteIpAddress; + if (remoteIp is not null) + { + return new IPEndPoint(remoteIp, ((IHttpConnectionFeature)this).RemotePort); + } + return null; + } + set + { + if (value is IPEndPoint remoteIPEndPoint) + { + ((IHttpConnectionFeature)this).RemoteIpAddress = remoteIPEndPoint.Address; + ((IHttpConnectionFeature)this).RemotePort = remoteIPEndPoint.Port; + } + } + } } diff --git a/src/Servers/IIS/IIS/src/Core/IISHttpContext.Features.cs b/src/Servers/IIS/IIS/src/Core/IISHttpContext.Features.cs index 6bce10267386..6894075e8578 100644 --- a/src/Servers/IIS/IIS/src/Core/IISHttpContext.Features.cs +++ b/src/Servers/IIS/IIS/src/Core/IISHttpContext.Features.cs @@ -30,6 +30,7 @@ internal partial class IISHttpContext private static readonly Type IHttpResponseTrailersFeature = typeof(global::Microsoft.AspNetCore.Http.Features.IHttpResponseTrailersFeature); private static readonly Type IHttpResetFeature = typeof(global::Microsoft.AspNetCore.Http.Features.IHttpResetFeature); private static readonly Type IConnectionLifetimeNotificationFeature = typeof(global::Microsoft.AspNetCore.Connections.Features.IConnectionLifetimeNotificationFeature); + private static readonly Type IConnectionEndPointFeature = typeof(global::Microsoft.AspNetCore.Connections.Features.IConnectionEndPointFeature); private static readonly Type IHttpActivityFeature = typeof(global::Microsoft.AspNetCore.Http.Features.IHttpActivityFeature); private static readonly Type IHttpSysRequestInfoFeature = typeof(global::Microsoft.AspNetCore.Server.HttpSys.IHttpSysRequestInfoFeature); private static readonly Type IHttpSysRequestTimingFeature = typeof(global::Microsoft.AspNetCore.Server.HttpSys.IHttpSysRequestTimingFeature); @@ -58,6 +59,7 @@ internal partial class IISHttpContext private object? _currentIHttpResponseTrailersFeature; private object? _currentIHttpResetFeature; private object? _currentIConnectionLifetimeNotificationFeature; + private object? _currentIConnectionEndPointFeature; private object? _currentIHttpActivityFeature; private object? _currentIHttpSysRequestInfoFeature; private object? _currentIHttpSysRequestTimingFeature; @@ -81,6 +83,7 @@ private void Initialize() _currentIHttpResponseTrailersFeature = GetResponseTrailersFeature(); _currentIHttpResetFeature = GetResetFeature(); _currentIConnectionLifetimeNotificationFeature = this; + _currentIConnectionEndPointFeature = this; _currentIHttpSysRequestInfoFeature = this; _currentIHttpSysRequestTimingFeature = this; @@ -189,6 +192,10 @@ private void Initialize() { return _currentIConnectionLifetimeNotificationFeature; } + if (key == IConnectionEndPointFeature) + { + return _currentIConnectionEndPointFeature; + } if (key == IHttpActivityFeature) { return _currentIHttpActivityFeature; @@ -337,6 +344,11 @@ internal void FastFeatureSet(Type key, object? feature) _currentIConnectionLifetimeNotificationFeature = feature; return; } + if (key == IConnectionEndPointFeature) + { + _currentIConnectionEndPointFeature = feature; + return; + } if (key == IHttpSysRequestInfoFeature) { _currentIHttpSysRequestInfoFeature = feature; @@ -444,6 +456,10 @@ private IEnumerable> FastEnumerable() { yield return new KeyValuePair(IHttpResponseTrailersFeature, _currentIHttpResetFeature); } + if (_currentIConnectionEndPointFeature != null) + { + yield return new KeyValuePair(IConnectionEndPointFeature, _currentIConnectionEndPointFeature); + } if (_currentIHttpActivityFeature != null) { yield return new KeyValuePair(IHttpActivityFeature, _currentIHttpActivityFeature); diff --git a/src/Servers/IIS/IIS/test/IIS.Tests/ConnectionEndPointFeatureTests.cs b/src/Servers/IIS/IIS/test/IIS.Tests/ConnectionEndPointFeatureTests.cs new file mode 100644 index 000000000000..1ce09e8f6a8b --- /dev/null +++ b/src/Servers/IIS/IIS/test/IIS.Tests/ConnectionEndPointFeatureTests.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.InternalTesting; +using Xunit; + +namespace Microsoft.AspNetCore.Server.IIS.FunctionalTests; + +[SkipIfHostableWebCoreNotAvailable] +[MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win8, SkipReason = "https://github.com/aspnet/IISIntegration/issues/866")] +public class ConnectionEndPointFeatureTests : StrictTestServerTests +{ + [ConditionalFact] + public async Task ProvidesLocalAndRemoteEndPoints() + { + EndPoint localEndPoint = null; + EndPoint remoteEndPoint = null; + using (var testServer = await TestServer.Create(ctx => + { + var endPointFeature = ctx.Features.Get(); + localEndPoint = endPointFeature.LocalEndPoint; + remoteEndPoint = endPointFeature.RemoteEndPoint; + return Task.CompletedTask; + }, LoggerFactory)) + { + await testServer.HttpClient.GetStringAsync("/"); + } + + Assert.NotNull(localEndPoint); + Assert.NotNull(remoteEndPoint); + var localIPEndPoint = Assert.IsType(localEndPoint); + var remoteIPEndPoint = Assert.IsType(remoteEndPoint); + Assert.NotEqual(0, localIPEndPoint.Port); + Assert.NotEqual(0, remoteIPEndPoint.Port); + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs index 8b87922f90a8..7dc09ced4fe6 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs @@ -4,13 +4,12 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; -using System.Net; -using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Security.Authentication; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Shared; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; @@ -321,43 +320,7 @@ private void TlsHandshakeStopCore(ConnectionMetricsContext metricsContext, long private static void InitializeConnectionTags(ref TagList tags, in ConnectionMetricsContext metricsContext) { - var localEndpoint = metricsContext.ConnectionContext.LocalEndPoint; - if (localEndpoint is IPEndPoint localIPEndPoint) - { - tags.Add("server.address", localIPEndPoint.Address.ToString()); - tags.Add("server.port", localIPEndPoint.Port); - - switch (localIPEndPoint.Address.AddressFamily) - { - case AddressFamily.InterNetwork: - tags.Add("network.type", "ipv4"); - break; - case AddressFamily.InterNetworkV6: - tags.Add("network.type", "ipv6"); - break; - } - - // There isn't an easy way to detect whether QUIC is the underlying transport. - // This code assumes that a multiplexed connection is QUIC. - // Improve in the future if there are additional multiplexed connection types. - var transport = metricsContext.ConnectionContext is not MultiplexedConnectionContext ? "tcp" : "udp"; - tags.Add("network.transport", transport); - } - else if (localEndpoint is UnixDomainSocketEndPoint udsEndPoint) - { - tags.Add("server.address", udsEndPoint.ToString()); - tags.Add("network.transport", "unix"); - } - else if (localEndpoint is NamedPipeEndPoint namedPipeEndPoint) - { - tags.Add("server.address", namedPipeEndPoint.ToString()); - tags.Add("network.transport", "pipe"); - } - else if (localEndpoint != null) - { - tags.Add("server.address", localEndpoint.ToString()); - tags.Add("network.transport", localEndpoint.AddressFamily.ToString()); - } + ConnectionEndpointTags.AddConnectionEndpointTags(ref tags, metricsContext.ConnectionContext); } public ConnectionMetricsContext CreateContext(BaseConnectionContext connection) diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj index 9ca9d863a5cc..cb7318993a6d 100644 --- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj +++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj @@ -37,6 +37,7 @@ + diff --git a/src/Servers/Kestrel/Core/test/TransportConnectionFeatureCollectionTests.cs b/src/Servers/Kestrel/Core/test/TransportConnectionFeatureCollectionTests.cs new file mode 100644 index 000000000000..d93126cc5049 --- /dev/null +++ b/src/Servers/Kestrel/Core/test/TransportConnectionFeatureCollectionTests.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.InternalTesting; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; + +public class TransportConnectionFeatureCollectionTests +{ + [Fact] + public void IConnectionEndPointFeature_IsAvailableInFeatureCollection() + { + var serviceContext = new TestServiceContext(); + var connection = new Mock { CallBase = true }.Object; + var transportConnectionManager = new TransportConnectionManager(serviceContext.ConnectionManager); + var kestrelConnection = CreateKestrelConnection(serviceContext, connection, transportConnectionManager); + + var endpointFeature = kestrelConnection.TransportConnection.Features.Get(); + + Assert.NotNull(endpointFeature); + } + + [Fact] + public void IConnectionEndPointFeature_ReturnsCorrectLocalEndPoint() + { + var serviceContext = new TestServiceContext(); + var connection = new Mock { CallBase = true }.Object; + var expectedLocalEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080); + connection.LocalEndPoint = expectedLocalEndPoint; + var transportConnectionManager = new TransportConnectionManager(serviceContext.ConnectionManager); + var kestrelConnection = CreateKestrelConnection(serviceContext, connection, transportConnectionManager); + + var endpointFeature = kestrelConnection.TransportConnection.Features.Get(); + + Assert.NotNull(endpointFeature); + Assert.Equal(expectedLocalEndPoint, endpointFeature.LocalEndPoint); + } + + [Fact] + public void IConnectionEndPointFeature_ReturnsCorrectRemoteEndPoint() + { + var serviceContext = new TestServiceContext(); + var connection = new Mock { CallBase = true }.Object; + var expectedRemoteEndPoint = new IPEndPoint(IPAddress.Parse("192.168.1.100"), 54321); + connection.RemoteEndPoint = expectedRemoteEndPoint; + var transportConnectionManager = new TransportConnectionManager(serviceContext.ConnectionManager); + var kestrelConnection = CreateKestrelConnection(serviceContext, connection, transportConnectionManager); + + var endpointFeature = kestrelConnection.TransportConnection.Features.Get(); + + Assert.NotNull(endpointFeature); + Assert.Equal(expectedRemoteEndPoint, endpointFeature.RemoteEndPoint); + } + + [Fact] + public void IConnectionEndPointFeature_AllowsSettingLocalEndPoint() + { + var serviceContext = new TestServiceContext(); + var connection = new Mock { CallBase = true }.Object; + var transportConnectionManager = new TransportConnectionManager(serviceContext.ConnectionManager); + var kestrelConnection = CreateKestrelConnection(serviceContext, connection, transportConnectionManager); + var newLocalEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9090); + + var endpointFeature = kestrelConnection.TransportConnection.Features.Get(); + endpointFeature.LocalEndPoint = newLocalEndPoint; + + Assert.Equal(newLocalEndPoint, kestrelConnection.TransportConnection.LocalEndPoint); + Assert.Equal(newLocalEndPoint, endpointFeature.LocalEndPoint); + } + + [Fact] + public void IConnectionEndPointFeature_AllowsSettingRemoteEndPoint() + { + var serviceContext = new TestServiceContext(); + var connection = new Mock { CallBase = true }.Object; + var transportConnectionManager = new TransportConnectionManager(serviceContext.ConnectionManager); + var kestrelConnection = CreateKestrelConnection(serviceContext, connection, transportConnectionManager); + var newRemoteEndPoint = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 12345); + + var endpointFeature = kestrelConnection.TransportConnection.Features.Get(); + endpointFeature.RemoteEndPoint = newRemoteEndPoint; + + Assert.Equal(newRemoteEndPoint, kestrelConnection.TransportConnection.RemoteEndPoint); + Assert.Equal(newRemoteEndPoint, endpointFeature.RemoteEndPoint); + } + + private static KestrelConnection CreateKestrelConnection(TestServiceContext serviceContext, DefaultConnectionContext connection, TransportConnectionManager transportConnectionManager, Func connectionDelegate = null) + { + connectionDelegate ??= _ => Task.CompletedTask; + + return new KestrelConnection( + id: 0, serviceContext, transportConnectionManager, connectionDelegate, connection, serviceContext.Log, TestContextFactory.CreateMetricsContext(connection)); + } +} \ No newline at end of file diff --git a/src/Servers/Kestrel/shared/TransportConnection.FeatureCollection.cs b/src/Servers/Kestrel/shared/TransportConnection.FeatureCollection.cs index 6aedabe43c5d..39f3c610b790 100644 --- a/src/Servers/Kestrel/shared/TransportConnection.FeatureCollection.cs +++ b/src/Servers/Kestrel/shared/TransportConnection.FeatureCollection.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.IO.Pipelines; +using System.Net; using Microsoft.AspNetCore.Connections.Features; #nullable enable @@ -37,4 +38,16 @@ CancellationToken IConnectionLifetimeFeature.ConnectionClosed } void IConnectionLifetimeFeature.Abort() => Abort(new ConnectionAbortedException("The connection was aborted by the application via IConnectionLifetimeFeature.Abort().")); + + EndPoint? IConnectionEndPointFeature.LocalEndPoint + { + get => LocalEndPoint; + set => LocalEndPoint = value; + } + + EndPoint? IConnectionEndPointFeature.RemoteEndPoint + { + get => RemoteEndPoint; + set => RemoteEndPoint = value; + } } diff --git a/src/Servers/Kestrel/shared/TransportConnection.Generated.cs b/src/Servers/Kestrel/shared/TransportConnection.Generated.cs index 303811649ac4..dcdeeea9d636 100644 --- a/src/Servers/Kestrel/shared/TransportConnection.Generated.cs +++ b/src/Servers/Kestrel/shared/TransportConnection.Generated.cs @@ -18,7 +18,8 @@ internal partial class TransportConnection : IFeatureCollection, IConnectionTransportFeature, IConnectionItemsFeature, IMemoryPoolFeature, - IConnectionLifetimeFeature + IConnectionLifetimeFeature, + IConnectionEndPointFeature { // Implemented features internal protected IConnectionIdFeature? _currentIConnectionIdFeature; @@ -26,6 +27,7 @@ internal partial class TransportConnection : IFeatureCollection, internal protected IConnectionItemsFeature? _currentIConnectionItemsFeature; internal protected IMemoryPoolFeature? _currentIMemoryPoolFeature; internal protected IConnectionLifetimeFeature? _currentIConnectionLifetimeFeature; + internal protected IConnectionEndPointFeature? _currentIConnectionEndPointFeature; // Other reserved feature slots internal protected IPersistentStateFeature? _currentIPersistentStateFeature; @@ -48,6 +50,7 @@ private void FastReset() _currentIConnectionItemsFeature = this; _currentIMemoryPoolFeature = this; _currentIConnectionLifetimeFeature = this; + _currentIConnectionEndPointFeature = this; _currentIPersistentStateFeature = null; _currentIConnectionSocketFeature = null; @@ -180,6 +183,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = _currentIConnectionMetricsTagsFeature; } + else if (key == typeof(IConnectionEndPointFeature)) + { + feature = _currentIConnectionEndPointFeature; + } else if (MaybeExtra != null) { feature = ExtraFeatureGet(key); @@ -244,6 +251,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIConnectionMetricsTagsFeature = (IConnectionMetricsTagsFeature?)value; } + else if (key == typeof(IConnectionEndPointFeature)) + { + _currentIConnectionEndPointFeature = (IConnectionEndPointFeature?)value; + } else { ExtraFeatureSet(key, value); @@ -310,6 +321,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = Unsafe.As(ref _currentIConnectionMetricsTagsFeature); } + else if (typeof(TFeature) == typeof(IConnectionEndPointFeature)) + { + feature = Unsafe.As(ref _currentIConnectionEndPointFeature); + } else if (MaybeExtra != null) { feature = (TFeature?)(ExtraFeatureGet(typeof(TFeature))); @@ -382,6 +397,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIConnectionMetricsTagsFeature = Unsafe.As(ref feature); } + else if (typeof(TFeature) == typeof(IConnectionEndPointFeature)) + { + _currentIConnectionEndPointFeature = Unsafe.As(ref feature); + } else { ExtraFeatureSet(typeof(TFeature), feature); @@ -442,6 +461,10 @@ private IEnumerable> FastEnumerable() { yield return new KeyValuePair(typeof(IConnectionMetricsTagsFeature), _currentIConnectionMetricsTagsFeature); } + if (_currentIConnectionEndPointFeature != null) + { + yield return new KeyValuePair(typeof(IConnectionEndPointFeature), _currentIConnectionEndPointFeature); + } if (MaybeExtra != null) { diff --git a/src/Servers/Kestrel/shared/TransportMultiplexedConnection.FeatureCollection.cs b/src/Servers/Kestrel/shared/TransportMultiplexedConnection.FeatureCollection.cs index a4727c0885b0..72feb040b846 100644 --- a/src/Servers/Kestrel/shared/TransportMultiplexedConnection.FeatureCollection.cs +++ b/src/Servers/Kestrel/shared/TransportMultiplexedConnection.FeatureCollection.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using System.Net; using Microsoft.AspNetCore.Connections.Features; namespace Microsoft.AspNetCore.Connections; @@ -28,4 +29,16 @@ CancellationToken IConnectionLifetimeFeature.ConnectionClosed } void IConnectionLifetimeFeature.Abort() => Abort(new ConnectionAbortedException("The connection was aborted by the application via IConnectionLifetimeFeature.Abort().")); + + EndPoint? IConnectionEndPointFeature.LocalEndPoint + { + get => LocalEndPoint; + set => LocalEndPoint = value; + } + + EndPoint? IConnectionEndPointFeature.RemoteEndPoint + { + get => RemoteEndPoint; + set => RemoteEndPoint = value; + } } diff --git a/src/Servers/Kestrel/shared/TransportMultiplexedConnection.Generated.cs b/src/Servers/Kestrel/shared/TransportMultiplexedConnection.Generated.cs index 60f5689a0f1c..22b95f65ef14 100644 --- a/src/Servers/Kestrel/shared/TransportMultiplexedConnection.Generated.cs +++ b/src/Servers/Kestrel/shared/TransportMultiplexedConnection.Generated.cs @@ -17,13 +17,15 @@ internal partial class TransportMultiplexedConnection : IFeatureCollection, IConnectionIdFeature, IConnectionItemsFeature, IMemoryPoolFeature, - IConnectionLifetimeFeature + IConnectionLifetimeFeature, + IConnectionEndPointFeature { // Implemented features internal protected IConnectionIdFeature? _currentIConnectionIdFeature; internal protected IConnectionItemsFeature? _currentIConnectionItemsFeature; internal protected IMemoryPoolFeature? _currentIMemoryPoolFeature; internal protected IConnectionLifetimeFeature? _currentIConnectionLifetimeFeature; + internal protected IConnectionEndPointFeature? _currentIConnectionEndPointFeature; // Other reserved feature slots internal protected IConnectionTransportFeature? _currentIConnectionTransportFeature; @@ -40,6 +42,7 @@ private void FastReset() _currentIConnectionItemsFeature = this; _currentIMemoryPoolFeature = this; _currentIConnectionLifetimeFeature = this; + _currentIConnectionEndPointFeature = this; _currentIConnectionTransportFeature = null; _currentIProtocolErrorCodeFeature = null; @@ -135,6 +138,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = _currentIConnectionLifetimeFeature; } + else if (key == typeof(IConnectionEndPointFeature)) + { + feature = _currentIConnectionEndPointFeature; + } else if (key == typeof(IProtocolErrorCodeFeature)) { feature = _currentIProtocolErrorCodeFeature; @@ -175,6 +182,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIConnectionLifetimeFeature = (IConnectionLifetimeFeature?)value; } + else if (key == typeof(IConnectionEndPointFeature)) + { + _currentIConnectionEndPointFeature = (IConnectionEndPointFeature?)value; + } else if (key == typeof(IProtocolErrorCodeFeature)) { _currentIProtocolErrorCodeFeature = (IProtocolErrorCodeFeature?)value; @@ -217,6 +228,10 @@ private void ExtraFeatureSet(Type key, object? value) { feature = Unsafe.As(ref _currentIConnectionLifetimeFeature); } + else if (typeof(TFeature) == typeof(IConnectionEndPointFeature)) + { + feature = Unsafe.As(ref _currentIConnectionEndPointFeature); + } else if (typeof(TFeature) == typeof(IProtocolErrorCodeFeature)) { feature = Unsafe.As(ref _currentIProtocolErrorCodeFeature); @@ -260,6 +275,10 @@ private void ExtraFeatureSet(Type key, object? value) { _currentIConnectionLifetimeFeature = Unsafe.As(ref feature); } + else if (typeof(TFeature) == typeof(IConnectionEndPointFeature)) + { + _currentIConnectionEndPointFeature = Unsafe.As(ref feature); + } else if (typeof(TFeature) == typeof(IProtocolErrorCodeFeature)) { _currentIProtocolErrorCodeFeature = Unsafe.As(ref feature); @@ -296,6 +315,10 @@ private IEnumerable> FastEnumerable() { yield return new KeyValuePair(typeof(IConnectionLifetimeFeature), _currentIConnectionLifetimeFeature); } + if (_currentIConnectionEndPointFeature != null) + { + yield return new KeyValuePair(typeof(IConnectionEndPointFeature), _currentIConnectionEndPointFeature); + } if (_currentIProtocolErrorCodeFeature != null) { yield return new KeyValuePair(typeof(IProtocolErrorCodeFeature), _currentIProtocolErrorCodeFeature); diff --git a/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs index cae7e34924c4..a2c9e921a4b4 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/TransportConnectionFeatureCollection.cs @@ -24,7 +24,8 @@ public static string GenerateFile() "IStreamIdFeature", "IStreamAbortFeature", "IStreamClosedFeature", - "IConnectionMetricsTagsFeature" + "IConnectionMetricsTagsFeature", + "IConnectionEndPointFeature" }; var implementedFeatures = new[] @@ -33,7 +34,8 @@ public static string GenerateFile() "IConnectionTransportFeature", "IConnectionItemsFeature", "IMemoryPoolFeature", - "IConnectionLifetimeFeature" + "IConnectionLifetimeFeature", + "IConnectionEndPointFeature" }; var usings = $@" diff --git a/src/Servers/Kestrel/tools/CodeGenerator/TransportMultiplexedConnectionFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/TransportMultiplexedConnectionFeatureCollection.cs index 29a4cd8a7c3a..dd4e5485caa7 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/TransportMultiplexedConnectionFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/TransportMultiplexedConnectionFeatureCollection.cs @@ -16,6 +16,7 @@ public static string GenerateFile() "IConnectionItemsFeature", "IMemoryPoolFeature", "IConnectionLifetimeFeature", + "IConnectionEndPointFeature", "IProtocolErrorCodeFeature", "ITlsConnectionFeature" }; @@ -24,7 +25,8 @@ public static string GenerateFile() "IConnectionIdFeature", "IConnectionItemsFeature", "IMemoryPoolFeature", - "IConnectionLifetimeFeature" + "IConnectionLifetimeFeature", + "IConnectionEndPointFeature" }; var usings = $@" diff --git a/src/Shared/ConnectionEndpointTags.cs b/src/Shared/ConnectionEndpointTags.cs new file mode 100644 index 000000000000..17e87ee80171 --- /dev/null +++ b/src/Shared/ConnectionEndpointTags.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; + +#nullable enable + +namespace Microsoft.AspNetCore.Shared; + +internal static class ConnectionEndpointTags +{ + /// + /// Adds connection endpoint tags to a TagList using . + /// + /// The to add tags to. + /// The feature collection to get endpoint information from. + public static void AddConnectionEndpointTags(ref TagList tags, IFeatureCollection features) + { + var endpointFeature = features.Get(); + if (endpointFeature is null) + { + return; + } + + // This overload only has endpoint information from the feature collection and does not attempt + // to infer whether the underlying transport is multiplexed or QUIC. For IP endpoints, it records + // the transport as TCP. + AddEndpointTags(ref tags, endpointFeature.LocalEndPoint, networkTransport: "tcp"); + } + + /// + /// Adds connection endpoint tags to a TagList using , + /// with a fallback to the endpoint properties. + /// + /// The to add tags to. + /// The connection context to get endpoint information from. + public static void AddConnectionEndpointTags(ref TagList tags, BaseConnectionContext connectionContext) + { + // Try to get the local endpoint from the feature first, then fall back to the direct property. + var localEndpoint = connectionContext.Features.Get()?.LocalEndPoint + ?? connectionContext.LocalEndPoint; + + // There isn't an easy way to detect whether QUIC is the underlying transport. + // This code assumes that a multiplexed connection is QUIC. + // Improve in the future if there are additional multiplexed connection types. + var networkTransport = connectionContext is not MultiplexedConnectionContext ? "tcp" : "udp"; + AddEndpointTags(ref tags, localEndpoint, networkTransport); + } + + private static void AddEndpointTags(ref TagList tags, EndPoint? localEndpoint, string networkTransport) + { + if (localEndpoint is IPEndPoint localIPEndPoint) + { + tags.Add("server.address", localIPEndPoint.Address.ToString()); + tags.Add("server.port", localIPEndPoint.Port); + + switch (localIPEndPoint.Address.AddressFamily) + { + case AddressFamily.InterNetwork: + tags.Add("network.type", "ipv4"); + break; + case AddressFamily.InterNetworkV6: + tags.Add("network.type", "ipv6"); + break; + } + + tags.Add("network.transport", networkTransport); + } + else if (localEndpoint is UnixDomainSocketEndPoint udsEndPoint) + { + tags.Add("server.address", udsEndPoint.ToString()); + tags.Add("network.transport", "unix"); + } + else if (localEndpoint is NamedPipeEndPoint namedPipeEndPoint) + { + tags.Add("server.address", namedPipeEndPoint.ToString()); + tags.Add("network.transport", "pipe"); + } + else if (localEndpoint != null) + { + tags.Add("server.address", localEndpoint.ToString()); + tags.Add("network.transport", localEndpoint.AddressFamily.ToString()); + } + } +} diff --git a/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.Tracing.cs b/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.Tracing.cs index 84ed84cc5de2..7c47a6f75b3f 100644 --- a/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.Tracing.cs +++ b/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.Tracing.cs @@ -744,4 +744,57 @@ public async Task SendAsync_Tracing(string protocolName, HttpTransportType trans Assert.Equal(ActivityKind.Client, clientActivity.Kind); } } + + [Theory] + [MemberData(nameof(HubProtocolsAndTransportsAndHubPaths))] + public async Task InvokeAsync_ServerActivityIncludesConnectionEndpointTags(string protocolName, HttpTransportType transportType, string path) + { + var protocol = HubProtocols[protocolName]; + await using (var server = await StartServer()) + { + var serverChannel = Channel.CreateUnbounded(); + var serverSource = server.Services.GetRequiredService().ActivitySource; + + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, serverSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => serverChannel.Writer.TryWrite(activity) + }; + ActivitySource.AddActivityListener(listener); + + var connection = CreateHubConnection(server.Url, path, transportType, protocol, LoggerFactory); + try + { + await connection.StartAsync().DefaultTimeout(); + var result = await connection.InvokeAsync(nameof(TestHub.HelloWorld)).DefaultTimeout(); + Assert.Equal("Hello World!", result); + } + catch (Exception ex) + { + LoggerFactory.CreateLogger().LogError(ex, "{ExceptionType} from test", ex.GetType().FullName); + throw; + } + finally + { + await connection.DisposeAsync().DefaultTimeout(); + } + + var port = new Uri(server.Url).Port; + // Read at least the OnConnected + HelloWorld activities + var serverActivities = await serverChannel.Reader.ReadAtLeastAsync(minimumCount: 2).DefaultTimeout(); + + // Verify server activities include connection endpoint tags + foreach (var activity in serverActivities.Where(a => a.DisplayName.Contains("OnConnected") || a.DisplayName.Contains("HelloWorld"))) + { + var tags = activity.TagObjects.ToDictionary(); + Assert.True(tags.ContainsKey("server.address"), $"Activity '{activity.DisplayName}' should have server.address tag"); + Assert.True(tags.ContainsKey("server.port"), $"Activity '{activity.DisplayName}' should have server.port tag"); + Assert.Equal(port, (int)tags["server.port"]); + Assert.Equal("127.0.0.1", tags["server.address"].ToString()); + Assert.True(tags.ContainsKey("network.type"), $"Activity '{activity.DisplayName}' should have network.type tag"); + Assert.True(tags.ContainsKey("network.transport"), $"Activity '{activity.DisplayName}' should have network.transport tag"); + } + } + } } diff --git a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs index b5b147d149e6..2f282b398955 100644 --- a/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs +++ b/src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs @@ -6,6 +6,7 @@ using System.Security.Principal; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http.Connections.Internal.Transports; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Timeouts; @@ -602,6 +603,10 @@ private async Task EnsureConnectionStateAsync(HttpConnectionContext connec // Set the IHttpConnectionFeature now that we can access it. connection.Features.Set(context.Features.Get()); + // Set the IConnectionEndPointFeature from the HttpContext so that real connection endpoint + // information (LocalEndPoint and RemoteEndPoint) is available for telemetry and other uses. + connection.Features.Set(context.Features.Get()); + // Configure transport-specific features. if (transportType == HttpTransportType.LongPolling) { diff --git a/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj b/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj index 5384bc87382e..c17805cc9f0e 100644 --- a/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj +++ b/src/SignalR/common/Http.Connections/src/Microsoft.AspNetCore.Http.Connections.csproj @@ -29,6 +29,7 @@ + diff --git a/src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs b/src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs index 5e914b31e29e..da58b44a2520 100644 --- a/src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs +++ b/src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs @@ -92,7 +92,7 @@ public override async Task OnConnectedAsync(HubConnectionContext connection) // OnConnectedAsync won't work with client results (ISingleClientProxy.InvokeAsync) InitializeHub(hub, connection, invokeAllowed: false); - activity = StartActivity(SignalRServerActivitySource.OnConnected, ActivityKind.Internal, linkedActivity: null, scope.ServiceProvider, nameof(hub.OnConnectedAsync), headers: null, _logger); + activity = StartActivity(SignalRServerActivitySource.OnConnected, ActivityKind.Internal, linkedActivity: null, scope.ServiceProvider, nameof(hub.OnConnectedAsync), headers: null, _logger, connection); if (_onConnectedMiddleware != null) { @@ -127,7 +127,7 @@ public override async Task OnDisconnectedAsync(HubConnectionContext connection, { InitializeHub(hub, connection); - activity = StartActivity(SignalRServerActivitySource.OnDisconnected, ActivityKind.Internal, linkedActivity: null, scope.ServiceProvider, nameof(hub.OnDisconnectedAsync), headers: null, _logger); + activity = StartActivity(SignalRServerActivitySource.OnDisconnected, ActivityKind.Internal, linkedActivity: null, scope.ServiceProvider, nameof(hub.OnDisconnectedAsync), headers: null, _logger, connection); if (_onDisconnectedMiddleware != null) { @@ -404,7 +404,7 @@ static async Task ExecuteInvocation(DefaultHubDispatcher dispatcher, // Use hubMethodInvocationMessage.Target instead of methodExecutor.MethodInfo.Name // We want to take HubMethodNameAttribute into account which will be the same as what the invocation target is - var activity = StartActivity(SignalRServerActivitySource.InvocationIn, ActivityKind.Server, connection.OriginalActivity, scope.ServiceProvider, hubMethodInvocationMessage.Target, hubMethodInvocationMessage.Headers, logger); + var activity = StartActivity(SignalRServerActivitySource.InvocationIn, ActivityKind.Server, connection.OriginalActivity, scope.ServiceProvider, hubMethodInvocationMessage.Target, hubMethodInvocationMessage.Headers, logger, connection); object? result; try @@ -522,7 +522,7 @@ private async Task StreamAsync(string invocationId, HubConnectionContext connect Activity.Current = null; } - var activity = StartActivity(SignalRServerActivitySource.InvocationIn, ActivityKind.Server, connection.OriginalActivity, scope.ServiceProvider, hubMethodInvocationMessage.Target, hubMethodInvocationMessage.Headers, _logger); + var activity = StartActivity(SignalRServerActivitySource.InvocationIn, ActivityKind.Server, connection.OriginalActivity, scope.ServiceProvider, hubMethodInvocationMessage.Target, hubMethodInvocationMessage.Headers, _logger, connection); try { @@ -829,7 +829,7 @@ public override IReadOnlyList GetParameterTypes(string methodName) // Starts an Activity for a Hub method invocation and sets up all the tags and other state. // Make sure to call Activity.Stop() once the Hub method completes, and consider calling SetActivityError on exception. - private static Activity? StartActivity(string operationName, ActivityKind kind, Activity? linkedActivity, IServiceProvider serviceProvider, string methodName, IDictionary? headers, ILogger logger) + private static Activity? StartActivity(string operationName, ActivityKind kind, Activity? linkedActivity, IServiceProvider serviceProvider, string methodName, IDictionary? headers, ILogger logger, HubConnectionContext? connection = null) { var activitySource = serviceProvider.GetService()?.ActivitySource; if (activitySource is null) @@ -843,15 +843,20 @@ public override IReadOnlyList GetParameterTypes(string methodName) return null; } - IEnumerable> tags = - [ - new("rpc.method", methodName), - new("rpc.system", "signalr"), - new("rpc.service", _fullHubName), - // See https://github.com/dotnet/aspnetcore/blob/027c60168383421750f01e427e4f749d0684bc02/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelMetrics.cs#L308 - // And https://github.com/dotnet/aspnetcore/issues/43786 - //new("server.address", ...), - ]; + var tagList = new TagList + { + { "rpc.method", methodName }, + { "rpc.system", "signalr" }, + { "rpc.service", _fullHubName } + }; + + // Add connection endpoint tags if connection is available + if (connection is not null) + { + ConnectionEndpointTags.AddConnectionEndpointTags(ref tagList, connection.Features); + } + + IEnumerable> tags = tagList; IEnumerable? links = (linkedActivity is not null) ? [new ActivityLink(linkedActivity.Context)] : null; Activity? activity; diff --git a/src/SignalR/server/Core/src/Microsoft.AspNetCore.SignalR.Core.csproj b/src/SignalR/server/Core/src/Microsoft.AspNetCore.SignalR.Core.csproj index 0f01a615142d..12116224204c 100644 --- a/src/SignalR/server/Core/src/Microsoft.AspNetCore.SignalR.Core.csproj +++ b/src/SignalR/server/Core/src/Microsoft.AspNetCore.SignalR.Core.csproj @@ -22,6 +22,7 @@ + diff --git a/src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTests.Activity.cs b/src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTests.Activity.cs index ba54aa4043b8..c3d1f7c4a79f 100644 --- a/src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTests.Activity.cs +++ b/src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTests.Activity.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Linq; +using System.Net; using System.Threading.Channels; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.SignalR.Internal; @@ -523,4 +525,96 @@ private static void AssertHubMethodActivity(Activity activity, Activity pa Assert.Equal(linkedActivity.SpanId, Assert.Single(activity.Links).Context.SpanId); } } + + [Fact] + public async Task HubMethodActivityIncludesConnectionEndpointTags() + { + using (StartVerifiableLog()) + { + var serverChannel = Channel.CreateUnbounded(); + var testSource = new ActivitySource("test_source"); + + var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(builder => + { + // Provided by hosting layer normally + builder.AddSingleton(testSource); + }, LoggerFactory); + var signalrSource = serviceProvider.GetRequiredService().ActivitySource; + + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource) || ReferenceEquals(activitySource, signalrSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = a => serverChannel.Writer.TryWrite(a) + }; + ActivitySource.AddActivityListener(listener); + + var mockHttpRequestActivity = new Activity("HttpRequest"); + mockHttpRequestActivity.Start(); + Activity.Current = mockHttpRequestActivity; + + var connectionHandler = serviceProvider.GetService>(); + + using (var client = new TestClient()) + { + // Set up endpoint information on the connection to ensure the tags are added + client.Connection.LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 5000); + client.Connection.RemoteEndPoint = new IPEndPoint(IPAddress.Parse("192.168.1.100"), 12345); + + var connectionHandlerTask = await client.ConnectAsync(connectionHandler).DefaultTimeout(); + + var connectActivity = await serverChannel.Reader.ReadAsync().DefaultTimeout(); + + // Verify that endpoint tags are included in the activity + // Note: Activity.Tags only includes string-valued tags; integer-valued tags (like server.port) use TagObjects. + var tags = connectActivity.Tags.ToArray(); + var tagObjects = connectActivity.TagObjects.ToArray(); + + // Should have the standard 3 rpc tags plus string endpoint tags (server.address, network.type, network.transport) + Assert.True(tags.Length >= 3, $"Expected at least 3 tags, but found {tags.Length}"); + + // Verify the standard SignalR tags are present + var rpcMethodTag = tags.FirstOrDefault(t => t.Key == "rpc.method"); + Assert.NotNull(rpcMethodTag.Key); + Assert.Equal(nameof(MethodHub.OnConnectedAsync), rpcMethodTag.Value); + + var rpcSystemTag = tags.FirstOrDefault(t => t.Key == "rpc.system"); + Assert.NotNull(rpcSystemTag.Key); + Assert.Equal("signalr", rpcSystemTag.Value); + + var rpcServiceTag = tags.FirstOrDefault(t => t.Key == "rpc.service"); + Assert.NotNull(rpcServiceTag.Key); + Assert.Equal(typeof(MethodHub).FullName, rpcServiceTag.Value); + + // Verify endpoint tags are present + var serverAddressTag = tags.FirstOrDefault(t => t.Key == "server.address"); + Assert.NotNull(serverAddressTag.Key); + Assert.Equal("127.0.0.1", serverAddressTag.Value); + + // server.port is stored as int per OTel semantic conventions, so use TagObjects + var serverPortTag = tagObjects.FirstOrDefault(t => t.Key == "server.port"); + Assert.NotNull(serverPortTag.Key); + Assert.Equal(5000, serverPortTag.Value); + + var networkTypeTag = tags.FirstOrDefault(t => t.Key == "network.type"); + Assert.NotNull(networkTypeTag.Key); + Assert.Equal("ipv4", networkTypeTag.Value); + + var networkTransportTag = tags.FirstOrDefault(t => t.Key == "network.transport"); + Assert.NotNull(networkTransportTag.Key); + Assert.Equal("tcp", networkTransportTag.Value); + + client.Dispose(); + await connectionHandlerTask; + } + + var disconnectActivity = await serverChannel.Reader.ReadAsync().DefaultTimeout(); + + // Verify disconnect activity also has endpoint tags + var disconnectTags = disconnectActivity.Tags.ToArray(); + var disconnectServerAddressTag = disconnectTags.FirstOrDefault(t => t.Key == "server.address"); + Assert.NotNull(disconnectServerAddressTag.Key); + Assert.Equal("127.0.0.1", disconnectServerAddressTag.Value); + } + } }