From ea4c9148af71a7772287a14ca40a94cc2a70e183 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 3 Feb 2026 15:46:02 -0800 Subject: [PATCH 01/19] feat: add http.route attribute to open telemetry on requests --- .../httpClient/HttpClientRequestAdapter.cs | 79 +++ ...pClientRequestAdapterObservabilityTests.cs | 474 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs index 0de49c91..66df0b16 100644 --- a/src/http/httpClient/HttpClientRequestAdapter.cs +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -114,8 +114,87 @@ public string? BaseUrl var telemetryPathValue = string.IsNullOrEmpty(decodedUriTemplate) ? string.Empty : queryParametersCleanupRegex.Replace(decodedUriTemplate, string.Empty); var span = activitySource?.StartActivity($"{methodName} - {telemetryPathValue}"); span?.SetTag("url.uri_template", decodedUriTemplate); + if(!string.IsNullOrEmpty(telemetryPathValue)) + { + var httpRoute = GetNormalizedHttpRoute(telemetryPathValue); + span?.SetTag("http.route", httpRoute); + } return span; } + + /// + /// Normalizes the URI template into an HTTP route for observability by removing + /// the base URL placeholder and prefix, ensuring the route starts with "/". + /// + /// The URI template path to normalize. + /// The normalized HTTP route. + internal string GetNormalizedHttpRoute(string telemetryPathValue) + { +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER + // Optimized path using spans to reduce string allocations + const string baseUrlPlaceholder = "{+baseurl}"; + var span = telemetryPathValue.AsSpan(); + + // Remove the base URL placeholder if present + var placeholderIndex = span.IndexOf(baseUrlPlaceholder.AsSpan(), StringComparison.OrdinalIgnoreCase); + if(placeholderIndex >= 0) + { + // Concatenate parts around the placeholder - reduces allocations by using spans + var withoutPlaceholder = string.Concat( + new string(span[..placeholderIndex]), + new string(span[(placeholderIndex + baseUrlPlaceholder.Length)..]) + ); + span = withoutPlaceholder.AsSpan(); + } + + // Remove the base URL prefix if present (zero-allocation slice) + if(!string.IsNullOrEmpty(BaseUrl) && span.StartsWith(BaseUrl.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + span = span[BaseUrl.Length..]; + } + + // Trim whitespace (zero-allocation) + span = span.Trim(); + + // If empty, return "/" + if(span.IsEmpty) + { + return "/"; + } + + // Trim leading slashes (zero-allocation slice) + span = span.TrimStart('/'); + + // Final allocation - create string with "/" prefix + return "/" + new string(span); +#else + // Fallback for older frameworks + var httpRoute = telemetryPathValue; + const string baseUrlPlaceholder = "{+baseurl}"; + + // Remove the base URL placeholder if present + var baseUrlPlaceholderIndex = httpRoute.IndexOf(baseUrlPlaceholder, StringComparison.OrdinalIgnoreCase); + if(baseUrlPlaceholderIndex >= 0) + { + httpRoute = httpRoute.Remove(baseUrlPlaceholderIndex, baseUrlPlaceholder.Length); + } + + // Remove the base URL prefix if the route starts with it + if(!string.IsNullOrEmpty(BaseUrl) && httpRoute.StartsWith(BaseUrl, StringComparison.OrdinalIgnoreCase)) + { + httpRoute = httpRoute.Substring(BaseUrl!.Length); + } + + // Normalize whitespace and ensure the route starts with "/" + httpRoute = httpRoute.Trim(); + if(string.IsNullOrEmpty(httpRoute)) + { + return "/"; + } + + return "/" + httpRoute.TrimStart('/'); +#endif + } /// /// Send a instance with a collection instance of /// diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs new file mode 100644 index 00000000..fb82d7bc --- /dev/null +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -0,0 +1,474 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests +{ + public class HttpClientRequestAdapterObservabilityTests : IDisposable + { + private readonly HttpClientRequestAdapter _requestAdapter; + private readonly ActivityListener _activityListener; + private readonly List _capturedActivities; + + public HttpClientRequestAdapterObservabilityTests() + { + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + _requestAdapter = new HttpClientRequestAdapter(authProvider, observabilityOptions: observabilityOptions); + _requestAdapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + // Setup activity listener to capture activities + _capturedActivities = new List(); + _activityListener = new ActivityListener + { + ShouldListenTo = source => source.Name == observabilityOptions.TracerInstrumentationName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => _capturedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(_activityListener); + } + + public void Dispose() + { + _activityListener?.Dispose(); + _requestAdapter?.Dispose(); + } + + #region GetNormalizedHttpRoute Tests + + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPlaceholder_RemovesPlaceholder() + { + // Arrange + var input = "{+baseurl}/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderCaseInsensitive_RemovesPlaceholder() + { + // Arrange + var input = "{+BASEURL}/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderInMiddle_RemovesPlaceholder() + { + // Arrange + var input = "prefix{+baseurl}/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/prefix/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPrefix_RemovesPrefix() + { + // Arrange + var input = "https://graph.microsoft.com/v1.0/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPrefixCaseInsensitive_RemovesPrefix() + { + // Arrange + var input = "HTTPS://GRAPH.MICROSOFT.COM/V1.0/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithLeadingSlash_PreservesSlash() + { + // Arrange + var input = "/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithoutLeadingSlash_AddsSlash() + { + // Arrange + var input = "users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithMultipleLeadingSlashes_NormalizesToSingleSlash() + { + // Arrange + var input = "///users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithLeadingWhitespace_TrimsAndAddsSlash() + { + // Arrange + var input = " users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithTrailingWhitespace_TrimsWhitespace() + { + // Arrange + var input = "users/{id} "; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithLeadingAndTrailingWhitespace_TrimsWhitespace() + { + // Arrange + var input = " users/{id} "; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithEmptyString_ReturnsRootSlash() + { + // Arrange + var input = ""; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithOnlyWhitespace_ReturnsRootSlash() + { + // Arrange + var input = " "; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithOnlyBaseUrlPlaceholder_ReturnsRootSlash() + { + // Arrange + var input = "{+baseurl}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithOnlyBaseUrl_ReturnsRootSlash() + { + // Arrange + var input = "https://graph.microsoft.com/v1.0"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlAndTrailingSlash_ReturnsRootSlash() + { + // Arrange + var input = "https://graph.microsoft.com/v1.0/"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithComplexPath_NormalizesCorrectly() + { + // Arrange + var input = "{+baseurl}/users/{user-id}/messages/{message-id}/attachments"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{user-id}/messages/{message-id}/attachments", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithQueryParameters_PreservesPath() + { + // Arrange - Note: query parameters should be removed before calling this method + var input = "/users/{id}"; + + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithBothPlaceholderAndBaseUrl_HandlesBothTransformations() + { + // Arrange + var input = "{+baseurl}/users"; + var adapterWithBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + adapterWithBaseUrl.BaseUrl = "https://api.example.com/v2"; + + // Act + var result = adapterWithBaseUrl.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users", result); + + // Cleanup + adapterWithBaseUrl.Dispose(); + } + + [Fact] + public void GetNormalizedHttpRoute_WithNullBaseUrl_OnlyRemovesPlaceholder() + { + // Arrange + var input = "{+baseurl}/users/{id}"; + var adapterWithoutBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + + // Act + var result = adapterWithoutBaseUrl.GetNormalizedHttpRoute(input); + + // Assert + Assert.Equal("/users/{id}", result); + + // Cleanup + adapterWithoutBaseUrl.Dispose(); + } + + #endregion + + #region Activity Span Tag Tests + + [Fact] + public async Task SendAsync_CreatesActivityWithHttpRouteTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users/{user-id}" + }; + requestInfo.PathParameters.Add("user-id", "john@contoso.com"); + + // Clear any previously captured activities + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch + { + // Ignore exceptions, we're only interested in the activity + } + + // Assert + var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); + Assert.NotNull(activity); + + var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); + Assert.NotNull(httpRouteTag.Key); + Assert.Equal("/users/{user-id}", httpRouteTag.Value); + } + + [Fact] + public async Task SendAsync_CreatesActivityWithUriTemplateTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users/{user-id}/messages" + }; + requestInfo.PathParameters.Add("user-id", "john@contoso.com"); + + // Clear any previously captured activities + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch + { + // Ignore exceptions, we're only interested in the activity + } + + // Assert + var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); + Assert.NotNull(activity); + + var uriTemplateTag = activity.Tags.FirstOrDefault(t => t.Key == "url.uri_template"); + Assert.NotNull(uriTemplateTag.Key); + Assert.Contains("/users/{user-id}/messages", uriTemplateTag.Value); + } + + [Fact] + public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}" + }; + + // Clear any previously captured activities + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch + { + // Ignore exceptions, we're only interested in the activity + } + + // Assert + var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); + Assert.NotNull(activity); + + var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); + Assert.NotNull(httpRouteTag.Key); + Assert.Equal("/", httpRouteTag.Value); + } + + #endregion + } +} From 50b1e7a82ab382a744f94b1f986c28919d020d95 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 3 Feb 2026 16:13:22 -0800 Subject: [PATCH 02/19] expanded tests to include some coverage of existing otel instrumentation --- ...pClientRequestAdapterObservabilityTests.cs | 251 +++++++++++++++++- ....Kiota.Http.HttpClientLibrary.Tests.csproj | 1 + 2 files changed, 244 insertions(+), 8 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index fb82d7bc..3ad21582 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -1,20 +1,67 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Net; using System.Net.Http; -using System.Threading.Tasks; using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions.Authentication; -using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Microsoft.Kiota.Serialization.Json; using Moq; using Moq.Protected; using Xunit; namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests { + /// + /// Tests for OpenTelemetry observability instrumentation in HttpClientRequestAdapter. + /// + /// + /// This test suite covers the basic OpenTelemetry Activity spans and tags that are instrumented + /// throughout the HTTP request lifecycle. It includes comprehensive unit tests for HTTP route + /// normalization and integration tests for Activity span creation with key semantic tags. + /// + /// + /// Test Coverage: + /// + /// HTTP route normalization (20 tests) - validates the GetNormalizedHttpRoute method + /// Activity span creation and nested spans (8 tests) + /// Basic semantic tags: http.route, url.uri_template, url.scheme, server.address, http.request.method + /// EUII attribute handling: url.full tag gating based on IncludeEUIIAttributes setting + /// + /// + /// Excluded Test Scenarios: + /// + /// The following OpenTelemetry tags and scenarios are NOT tested due to infrastructure limitations + /// with mocking the full HTTP request/response pipeline. These tags are only set after successful + /// HTTP responses or during error handling deep within the middleware stack, which requires complex + /// mocking of HttpClient internals, middleware behavior, and serialization: + /// + /// + /// http.response.status_code - Response status code (e.g., "200", "404") + /// network.protocol.name - HTTP protocol version (e.g., "1.1", "2.0") + /// http.request.header.content-type - Request body content type + /// http.request.body.size - Request body size in bytes + /// http.response.header.content-type - Response body content type + /// http.response.body.size - Response body size in bytes + /// com.microsoft.kiota.error.mapping_found - Whether an error mapping was found + /// com.microsoft.kiota.response.type - The response model type name + /// Deserialization spans - GetCollectionOfObjectValues and related deserialization activities + /// + /// + /// + /// Testing these scenarios would require: + /// + /// + /// Full HttpMessageHandler mocking with proper Content stream handling + /// Mocking the entire middleware pipeline execution + /// Setting up proper serialization/deserialization infrastructure + /// Ensuring Activities remain active throughout async operations + /// + /// + /// + /// These scenarios are better validated through integration tests with real HTTP endpoints + /// or end-to-end testing frameworks rather than unit tests with mocks. + /// + /// public class HttpClientRequestAdapterObservabilityTests : IDisposable { private readonly HttpClientRequestAdapter _requestAdapter; @@ -23,18 +70,23 @@ public class HttpClientRequestAdapterObservabilityTests : IDisposable public HttpClientRequestAdapterObservabilityTests() { + // Register JSON serialization for tests + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + var authProvider = new AnonymousAuthenticationProvider(); var observabilityOptions = new ObservabilityOptions(); _requestAdapter = new HttpClientRequestAdapter(authProvider, observabilityOptions: observabilityOptions); _requestAdapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Setup activity listener to capture activities + // Setup activity listener to capture activities from all Kiota sources _capturedActivities = new List(); _activityListener = new ActivityListener { - ShouldListenTo = source => source.Name == observabilityOptions.TracerInstrumentationName, + ShouldListenTo = source => source.Name.StartsWith("Microsoft.Kiota", StringComparison.OrdinalIgnoreCase), Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStarted = activity => _capturedActivities.Add(activity) + ActivityStarted = activity => _capturedActivities.Add(activity), + ActivityStopped = activity => { } // Capture stopped activities to ensure tags are set }; ActivitySource.AddActivityListener(_activityListener); } @@ -45,6 +97,13 @@ public void Dispose() _requestAdapter?.Dispose(); } + private KeyValuePair GetTagFromActivities(string tagKey) + { + return _capturedActivities + .SelectMany(a => a.Tags) + .FirstOrDefault(t => t.Key == tagKey); + } + #region GetNormalizedHttpRoute Tests [Fact] @@ -469,6 +528,182 @@ public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() Assert.Equal("/", httpRouteTag.Value); } + [Fact] + public async Task SendAsync_SetsHttpRequestMethodTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.POST, + UrlTemplate = "{+baseurl}/users" + }; + + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch { } + + // Assert + Assert.NotEmpty(_capturedActivities); + + var methodTag = GetTagFromActivities("http.request.method"); + Assert.NotNull(methodTag.Key); + Assert.Equal("POST", methodTag.Value); + } + + [Fact] + public async Task SendAsync_SetsUrlSchemeAndServerAddressTags() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users" + }; + + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch { } + + // Assert + Assert.NotEmpty(_capturedActivities); + + var schemeTag = GetTagFromActivities("url.scheme"); + Assert.Equal("https", schemeTag.Value); + + var serverTag = GetTagFromActivities("server.address"); + Assert.Equal("graph.microsoft.com", serverTag.Value); + } + + [Fact] + public async Task SendAsync_WithoutIncludeEUIIAttributes_DoesNotSetUrlFullTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions { IncludeEUIIAttributes = false }; + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users" + }; + + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch { } + + // Assert + Assert.NotEmpty(_capturedActivities); + + var urlFullTag = GetTagFromActivities("url.full"); + Assert.Null(urlFullTag.Key); + } + + [Fact] + public async Task SendAsync_CreatesNestedActivitySpans() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users" + }; + + _capturedActivities.Clear(); + + // Act + try + { + await adapter.SendAsync(requestInfo, MockEntity.Factory); + } + catch { } + + // Assert - Verify various nested spans are created + Assert.Contains(_capturedActivities, a => a.OperationName.Contains("SendAsync")); + Assert.Contains(_capturedActivities, a => a.OperationName.Contains("GetHttpResponseMessageAsync")); + Assert.Contains(_capturedActivities, a => a.OperationName.Contains("GetRequestMessageFromRequestInformation")); + Assert.Contains(_capturedActivities, a => a.OperationName.Contains("GetRootParseNodeAsync")); + } + #endregion } } diff --git a/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj b/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj index 4685f2fd..73ad26ea 100644 --- a/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj +++ b/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj @@ -21,6 +21,7 @@ + \ No newline at end of file From 03f0e6e42abcbaaef7b0ffe464c95ab8b124cb76 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 08:16:15 -0500 Subject: [PATCH 03/19] Potential fix for pull request finding 'Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../httpClient/HttpClientRequestAdapterObservabilityTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 3ad21582..5b32b0c9 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -388,7 +388,7 @@ public async Task SendAsync_CreatesActivityWithHttpRouteTag() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") From f0171bd9cbd68c1d87edc9cd8bee6ae2b3983089 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 08:28:17 -0500 Subject: [PATCH 04/19] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../HttpClientRequestAdapterObservabilityTests.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 5b32b0c9..22dffa41 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -602,11 +602,7 @@ public async Task SendAsync_SetsUrlSchemeAndServerAddressTags() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch { } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert Assert.NotEmpty(_capturedActivities); @@ -691,11 +687,7 @@ public async Task SendAsync_CreatesNestedActivitySpans() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch { } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert - Verify various nested spans are created Assert.Contains(_capturedActivities, a => a.OperationName.Contains("SendAsync")); From ba384ace9aa5f5da6a7cfc9d9af1bcd52ae2c99f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 08:35:01 -0500 Subject: [PATCH 05/19] Update tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../HttpClientRequestAdapterObservabilityTests.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 22dffa41..32730c72 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -644,11 +644,7 @@ public async Task SendAsync_WithoutIncludeEUIIAttributes_DoesNotSetUrlFullTag() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch { } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert Assert.NotEmpty(_capturedActivities); From 0ea0d300c5a33549544063a5e6358ca35bbd40f0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:41:30 -0500 Subject: [PATCH 06/19] Fix HttpResponseMessage disposal in Moq test setups (#645) * Initial plan * Fix HttpResponseMessage disposal issues in all observability tests Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../HttpClientRequestAdapterObservabilityTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 32730c72..791c296e 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -438,7 +438,7 @@ public async Task SendAsync_CreatesActivityWithUriTemplateTag() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") @@ -488,7 +488,7 @@ public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") @@ -537,7 +537,7 @@ public async Task SendAsync_SetsHttpRequestMethodTag() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") @@ -581,7 +581,7 @@ public async Task SendAsync_SetsUrlSchemeAndServerAddressTags() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") @@ -623,7 +623,7 @@ public async Task SendAsync_WithoutIncludeEUIIAttributes_DoesNotSetUrlFullTag() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") @@ -662,7 +662,7 @@ public async Task SendAsync_CreatesNestedActivitySpans() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage + .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent("{\"id\":\"123\"}") From 02a02fe6995019660fa92b7fd655379910be7328 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:42:18 -0500 Subject: [PATCH 07/19] Wrap HttpResponseMessage instantiation with lambda in Moq ReturnsAsync (#644) * Initial plan * Fix HttpResponseMessage disposal in all ReturnsAsync calls Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> From 2a13ceb989b6c6b1741adb63eacc3901ed88b5ce Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:42:59 -0500 Subject: [PATCH 08/19] Fix HttpResponseMessage disposal in mock test setups (#642) * Initial plan * Fix HttpResponseMessage disposal in remaining test methods Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> From 4acdcbe255358b48ebff1718b6728f1492081e38 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:48:57 -0500 Subject: [PATCH 09/19] Fix HttpResponseMessage disposal in mock test setups (#643) * Initial plan * Fix HttpResponseMessage disposal issues in observability tests Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> From bc39434b0d83e4e607a4d4c6af4bb7044ef8edf4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:52:48 -0500 Subject: [PATCH 10/19] Fix mock response content-type in observability tests (#646) * Initial plan * Fix empty catch blocks by adding proper content-type to mock responses Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- ...pClientRequestAdapterObservabilityTests.cs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 791c296e..9286a8d7 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Net; using System.Net.Http; +using System.Text; using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; @@ -391,7 +392,7 @@ public async Task SendAsync_CreatesActivityWithHttpRouteTag() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); @@ -441,7 +442,7 @@ public async Task SendAsync_CreatesActivityWithUriTemplateTag() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); @@ -491,7 +492,7 @@ public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); @@ -540,7 +541,7 @@ public async Task SendAsync_SetsHttpRequestMethodTag() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); @@ -558,11 +559,7 @@ public async Task SendAsync_SetsHttpRequestMethodTag() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch { } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert Assert.NotEmpty(_capturedActivities); @@ -584,7 +581,7 @@ public async Task SendAsync_SetsUrlSchemeAndServerAddressTags() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); @@ -626,7 +623,7 @@ public async Task SendAsync_WithoutIncludeEUIIAttributes_DoesNotSetUrlFullTag() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); @@ -665,7 +662,7 @@ public async Task SendAsync_CreatesNestedActivitySpans() .ReturnsAsync(() => new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}") + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object); From 7f91f9b271946941176985a20ca3bd7b6ebd457a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 09:00:11 -0500 Subject: [PATCH 11/19] chore: seals the test class because it implements IDisposable --- .../httpClient/HttpClientRequestAdapterObservabilityTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 9286a8d7..f05443fe 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -63,7 +63,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests /// or end-to-end testing frameworks rather than unit tests with mocks. /// /// - public class HttpClientRequestAdapterObservabilityTests : IDisposable + public sealed class HttpClientRequestAdapterObservabilityTests : IDisposable { private readonly HttpClientRequestAdapter _requestAdapter; private readonly ActivityListener _activityListener; From b7decc51c3c99fe76f864e8d961ebcd578b97714 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:03:44 -0500 Subject: [PATCH 12/19] Fix observability test setup to use correct content type (#647) * Initial plan * Fix empty catch blocks by adding explanatory comments Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Fix test setup to avoid exceptions instead of silencing them Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> From 059cdcac0f6074c8404b42e933a6e32f1427c4e1 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 09:06:17 -0500 Subject: [PATCH 13/19] chore: removes try catch from tests --- ...pClientRequestAdapterObservabilityTests.cs | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index f05443fe..ccae063b 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -412,14 +412,7 @@ public async Task SendAsync_CreatesActivityWithHttpRouteTag() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch - { - // Ignore exceptions, we're only interested in the activity - } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); @@ -462,14 +455,7 @@ public async Task SendAsync_CreatesActivityWithUriTemplateTag() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch - { - // Ignore exceptions, we're only interested in the activity - } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); @@ -511,14 +497,7 @@ public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() _capturedActivities.Clear(); // Act - try - { - await adapter.SendAsync(requestInfo, MockEntity.Factory); - } - catch - { - // Ignore exceptions, we're only interested in the activity - } + await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); From 3c9ecd6b44a85932960718a3e0d7ec566dd3842d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:15:51 -0500 Subject: [PATCH 14/19] Deduplicate baseUrlPlaceholder constant in GetNormalizedHttpRoute (#648) * Initial plan * Deduplicate baseUrlPlaceholder constant definition Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- src/http/httpClient/HttpClientRequestAdapter.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs index 66df0b16..9f59d3ff 100644 --- a/src/http/httpClient/HttpClientRequestAdapter.cs +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -130,9 +130,9 @@ public string? BaseUrl /// The normalized HTTP route. internal string GetNormalizedHttpRoute(string telemetryPathValue) { + const string baseUrlPlaceholder = "{+baseurl}"; #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER // Optimized path using spans to reduce string allocations - const string baseUrlPlaceholder = "{+baseurl}"; var span = telemetryPathValue.AsSpan(); // Remove the base URL placeholder if present @@ -170,7 +170,6 @@ internal string GetNormalizedHttpRoute(string telemetryPathValue) #else // Fallback for older frameworks var httpRoute = telemetryPathValue; - const string baseUrlPlaceholder = "{+baseurl}"; // Remove the base URL placeholder if present var baseUrlPlaceholderIndex = httpRoute.IndexOf(baseUrlPlaceholder, StringComparison.OrdinalIgnoreCase); From b89ed9be762819520824aff840f9643ad38ffa62 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 09:45:52 -0500 Subject: [PATCH 15/19] chore: fixes tests definitions Signed-off-by: Vincent Biret --- ...ttpClientRequestAdapterObservabilityTests.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index ccae063b..9f1c83e4 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -404,9 +404,9 @@ public async Task SendAsync_CreatesActivityWithHttpRouteTag() var requestInfo = new RequestInformation { HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users/{user-id}" + UrlTemplate = "{+baseurl}/users/{user%2Did}" }; - requestInfo.PathParameters.Add("user-id", "john@contoso.com"); + requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); // Clear any previously captured activities _capturedActivities.Clear(); @@ -447,9 +447,9 @@ public async Task SendAsync_CreatesActivityWithUriTemplateTag() var requestInfo = new RequestInformation { HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users/{user-id}/messages" + UrlTemplate = "{+baseurl}/users/{user%2Did}/messages" }; - requestInfo.PathParameters.Add("user-id", "john@contoso.com"); + requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); // Clear any previously captured activities _capturedActivities.Clear(); @@ -662,10 +662,11 @@ public async Task SendAsync_CreatesNestedActivitySpans() await adapter.SendAsync(requestInfo, MockEntity.Factory); // Assert - Verify various nested spans are created - Assert.Contains(_capturedActivities, a => a.OperationName.Contains("SendAsync")); - Assert.Contains(_capturedActivities, a => a.OperationName.Contains("GetHttpResponseMessageAsync")); - Assert.Contains(_capturedActivities, a => a.OperationName.Contains("GetRequestMessageFromRequestInformation")); - Assert.Contains(_capturedActivities, a => a.OperationName.Contains("GetRootParseNodeAsync")); + var resultingActivities = new HashSet(_capturedActivities.Select(static a => a.OperationName), StringComparer.Ordinal); + Assert.Contains("SendAsync - {+baseurl}/users", resultingActivities); + Assert.Contains("GetHttpResponseMessageAsync", resultingActivities); + Assert.Contains("GetRequestMessageFromRequestInformation", resultingActivities); + Assert.Contains("GetRootParseNodeAsync", resultingActivities); } #endregion From a71f0369e196aa12b9f22f1c03b3f782fd909ddf Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 09:46:08 -0500 Subject: [PATCH 16/19] chore: formatting Signed-off-by: Vincent Biret --- ...pClientRequestAdapterObservabilityTests.cs | 1095 ++++++++--------- 1 file changed, 547 insertions(+), 548 deletions(-) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 9f1c83e4..90124b33 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -10,665 +10,664 @@ using Moq.Protected; using Xunit; -namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests; + +/// +/// Tests for OpenTelemetry observability instrumentation in HttpClientRequestAdapter. +/// +/// +/// This test suite covers the basic OpenTelemetry Activity spans and tags that are instrumented +/// throughout the HTTP request lifecycle. It includes comprehensive unit tests for HTTP route +/// normalization and integration tests for Activity span creation with key semantic tags. +/// +/// +/// Test Coverage: +/// +/// HTTP route normalization (20 tests) - validates the GetNormalizedHttpRoute method +/// Activity span creation and nested spans (8 tests) +/// Basic semantic tags: http.route, url.uri_template, url.scheme, server.address, http.request.method +/// EUII attribute handling: url.full tag gating based on IncludeEUIIAttributes setting +/// +/// +/// Excluded Test Scenarios: +/// +/// The following OpenTelemetry tags and scenarios are NOT tested due to infrastructure limitations +/// with mocking the full HTTP request/response pipeline. These tags are only set after successful +/// HTTP responses or during error handling deep within the middleware stack, which requires complex +/// mocking of HttpClient internals, middleware behavior, and serialization: +/// +/// +/// http.response.status_code - Response status code (e.g., "200", "404") +/// network.protocol.name - HTTP protocol version (e.g., "1.1", "2.0") +/// http.request.header.content-type - Request body content type +/// http.request.body.size - Request body size in bytes +/// http.response.header.content-type - Response body content type +/// http.response.body.size - Response body size in bytes +/// com.microsoft.kiota.error.mapping_found - Whether an error mapping was found +/// com.microsoft.kiota.response.type - The response model type name +/// Deserialization spans - GetCollectionOfObjectValues and related deserialization activities +/// +/// +/// +/// Testing these scenarios would require: +/// +/// +/// Full HttpMessageHandler mocking with proper Content stream handling +/// Mocking the entire middleware pipeline execution +/// Setting up proper serialization/deserialization infrastructure +/// Ensuring Activities remain active throughout async operations +/// +/// +/// +/// These scenarios are better validated through integration tests with real HTTP endpoints +/// or end-to-end testing frameworks rather than unit tests with mocks. +/// +/// +public sealed class HttpClientRequestAdapterObservabilityTests : IDisposable { - /// - /// Tests for OpenTelemetry observability instrumentation in HttpClientRequestAdapter. - /// - /// - /// This test suite covers the basic OpenTelemetry Activity spans and tags that are instrumented - /// throughout the HTTP request lifecycle. It includes comprehensive unit tests for HTTP route - /// normalization and integration tests for Activity span creation with key semantic tags. - /// - /// - /// Test Coverage: - /// - /// HTTP route normalization (20 tests) - validates the GetNormalizedHttpRoute method - /// Activity span creation and nested spans (8 tests) - /// Basic semantic tags: http.route, url.uri_template, url.scheme, server.address, http.request.method - /// EUII attribute handling: url.full tag gating based on IncludeEUIIAttributes setting - /// - /// - /// Excluded Test Scenarios: - /// - /// The following OpenTelemetry tags and scenarios are NOT tested due to infrastructure limitations - /// with mocking the full HTTP request/response pipeline. These tags are only set after successful - /// HTTP responses or during error handling deep within the middleware stack, which requires complex - /// mocking of HttpClient internals, middleware behavior, and serialization: - /// - /// - /// http.response.status_code - Response status code (e.g., "200", "404") - /// network.protocol.name - HTTP protocol version (e.g., "1.1", "2.0") - /// http.request.header.content-type - Request body content type - /// http.request.body.size - Request body size in bytes - /// http.response.header.content-type - Response body content type - /// http.response.body.size - Response body size in bytes - /// com.microsoft.kiota.error.mapping_found - Whether an error mapping was found - /// com.microsoft.kiota.response.type - The response model type name - /// Deserialization spans - GetCollectionOfObjectValues and related deserialization activities - /// - /// - /// - /// Testing these scenarios would require: - /// - /// - /// Full HttpMessageHandler mocking with proper Content stream handling - /// Mocking the entire middleware pipeline execution - /// Setting up proper serialization/deserialization infrastructure - /// Ensuring Activities remain active throughout async operations - /// - /// - /// - /// These scenarios are better validated through integration tests with real HTTP endpoints - /// or end-to-end testing frameworks rather than unit tests with mocks. - /// - /// - public sealed class HttpClientRequestAdapterObservabilityTests : IDisposable - { - private readonly HttpClientRequestAdapter _requestAdapter; - private readonly ActivityListener _activityListener; - private readonly List _capturedActivities; + private readonly HttpClientRequestAdapter _requestAdapter; + private readonly ActivityListener _activityListener; + private readonly List _capturedActivities; - public HttpClientRequestAdapterObservabilityTests() - { - // Register JSON serialization for tests - ApiClientBuilder.RegisterDefaultSerializer(); - ApiClientBuilder.RegisterDefaultDeserializer(); - - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - _requestAdapter = new HttpClientRequestAdapter(authProvider, observabilityOptions: observabilityOptions); - _requestAdapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - // Setup activity listener to capture activities from all Kiota sources - _capturedActivities = new List(); - _activityListener = new ActivityListener - { - ShouldListenTo = source => source.Name.StartsWith("Microsoft.Kiota", StringComparison.OrdinalIgnoreCase), - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStarted = activity => _capturedActivities.Add(activity), - ActivityStopped = activity => { } // Capture stopped activities to ensure tags are set - }; - ActivitySource.AddActivityListener(_activityListener); - } - - public void Dispose() + public HttpClientRequestAdapterObservabilityTests() + { + // Register JSON serialization for tests + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + _requestAdapter = new HttpClientRequestAdapter(authProvider, observabilityOptions: observabilityOptions); + _requestAdapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + + // Setup activity listener to capture activities from all Kiota sources + _capturedActivities = new List(); + _activityListener = new ActivityListener { - _activityListener?.Dispose(); - _requestAdapter?.Dispose(); - } + ShouldListenTo = source => source.Name.StartsWith("Microsoft.Kiota", StringComparison.OrdinalIgnoreCase), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => _capturedActivities.Add(activity), + ActivityStopped = activity => { } // Capture stopped activities to ensure tags are set + }; + ActivitySource.AddActivityListener(_activityListener); + } - private KeyValuePair GetTagFromActivities(string tagKey) - { - return _capturedActivities - .SelectMany(a => a.Tags) - .FirstOrDefault(t => t.Key == tagKey); - } + public void Dispose() + { + _activityListener?.Dispose(); + _requestAdapter?.Dispose(); + } - #region GetNormalizedHttpRoute Tests + private KeyValuePair GetTagFromActivities(string tagKey) + { + return _capturedActivities + .SelectMany(a => a.Tags) + .FirstOrDefault(t => t.Key == tagKey); + } - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPlaceholder_RemovesPlaceholder() - { - // Arrange - var input = "{+baseurl}/users/{id}"; + #region GetNormalizedHttpRoute Tests - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPlaceholder_RemovesPlaceholder() + { + // Arrange + var input = "{+baseurl}/users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderCaseInsensitive_RemovesPlaceholder() - { - // Arrange - var input = "{+BASEURL}/users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderCaseInsensitive_RemovesPlaceholder() + { + // Arrange + var input = "{+BASEURL}/users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderInMiddle_RemovesPlaceholder() - { - // Arrange - var input = "prefix{+baseurl}/users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderInMiddle_RemovesPlaceholder() + { + // Arrange + var input = "prefix{+baseurl}/users/{id}"; - // Assert - Assert.Equal("/prefix/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPrefix_RemovesPrefix() - { - // Arrange - var input = "https://graph.microsoft.com/v1.0/users/{id}"; + // Assert + Assert.Equal("/prefix/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPrefix_RemovesPrefix() + { + // Arrange + var input = "https://graph.microsoft.com/v1.0/users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPrefixCaseInsensitive_RemovesPrefix() - { - // Arrange - var input = "HTTPS://GRAPH.MICROSOFT.COM/V1.0/users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlPrefixCaseInsensitive_RemovesPrefix() + { + // Arrange + var input = "HTTPS://GRAPH.MICROSOFT.COM/V1.0/users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithLeadingSlash_PreservesSlash() - { - // Arrange - var input = "/users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithLeadingSlash_PreservesSlash() + { + // Arrange + var input = "/users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithoutLeadingSlash_AddsSlash() - { - // Arrange - var input = "users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithoutLeadingSlash_AddsSlash() + { + // Arrange + var input = "users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithMultipleLeadingSlashes_NormalizesToSingleSlash() - { - // Arrange - var input = "///users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithMultipleLeadingSlashes_NormalizesToSingleSlash() + { + // Arrange + var input = "///users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithLeadingWhitespace_TrimsAndAddsSlash() - { - // Arrange - var input = " users/{id}"; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithLeadingWhitespace_TrimsAndAddsSlash() + { + // Arrange + var input = " users/{id}"; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithTrailingWhitespace_TrimsWhitespace() - { - // Arrange - var input = "users/{id} "; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithTrailingWhitespace_TrimsWhitespace() + { + // Arrange + var input = "users/{id} "; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithLeadingAndTrailingWhitespace_TrimsWhitespace() - { - // Arrange - var input = " users/{id} "; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithLeadingAndTrailingWhitespace_TrimsWhitespace() + { + // Arrange + var input = " users/{id} "; - // Assert - Assert.Equal("/users/{id}", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithEmptyString_ReturnsRootSlash() - { - // Arrange - var input = ""; + // Assert + Assert.Equal("/users/{id}", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithEmptyString_ReturnsRootSlash() + { + // Arrange + var input = ""; - // Assert - Assert.Equal("/", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithOnlyWhitespace_ReturnsRootSlash() - { - // Arrange - var input = " "; + // Assert + Assert.Equal("/", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithOnlyWhitespace_ReturnsRootSlash() + { + // Arrange + var input = " "; - // Assert - Assert.Equal("/", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithOnlyBaseUrlPlaceholder_ReturnsRootSlash() - { - // Arrange - var input = "{+baseurl}"; + // Assert + Assert.Equal("/", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithOnlyBaseUrlPlaceholder_ReturnsRootSlash() + { + // Arrange + var input = "{+baseurl}"; - // Assert - Assert.Equal("/", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithOnlyBaseUrl_ReturnsRootSlash() - { - // Arrange - var input = "https://graph.microsoft.com/v1.0"; + // Assert + Assert.Equal("/", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithOnlyBaseUrl_ReturnsRootSlash() + { + // Arrange + var input = "https://graph.microsoft.com/v1.0"; - // Assert - Assert.Equal("/", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlAndTrailingSlash_ReturnsRootSlash() - { - // Arrange - var input = "https://graph.microsoft.com/v1.0/"; + // Assert + Assert.Equal("/", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithBaseUrlAndTrailingSlash_ReturnsRootSlash() + { + // Arrange + var input = "https://graph.microsoft.com/v1.0/"; - // Assert - Assert.Equal("/", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithComplexPath_NormalizesCorrectly() - { - // Arrange - var input = "{+baseurl}/users/{user-id}/messages/{message-id}/attachments"; + // Assert + Assert.Equal("/", result); + } - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + [Fact] + public void GetNormalizedHttpRoute_WithComplexPath_NormalizesCorrectly() + { + // Arrange + var input = "{+baseurl}/users/{user-id}/messages/{message-id}/attachments"; - // Assert - Assert.Equal("/users/{user-id}/messages/{message-id}/attachments", result); - } + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - [Fact] - public void GetNormalizedHttpRoute_WithQueryParameters_PreservesPath() - { - // Arrange - Note: query parameters should be removed before calling this method - var input = "/users/{id}"; + // Assert + Assert.Equal("/users/{user-id}/messages/{message-id}/attachments", result); + } + + [Fact] + public void GetNormalizedHttpRoute_WithQueryParameters_PreservesPath() + { + // Arrange - Note: query parameters should be removed before calling this method + var input = "/users/{id}"; - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); + // Act + var result = _requestAdapter.GetNormalizedHttpRoute(input); - // Assert - Assert.Equal("/users/{id}", result); - } + // Assert + Assert.Equal("/users/{id}", result); + } - [Fact] - public void GetNormalizedHttpRoute_WithBothPlaceholderAndBaseUrl_HandlesBothTransformations() - { - // Arrange - var input = "{+baseurl}/users"; - var adapterWithBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); - adapterWithBaseUrl.BaseUrl = "https://api.example.com/v2"; + [Fact] + public void GetNormalizedHttpRoute_WithBothPlaceholderAndBaseUrl_HandlesBothTransformations() + { + // Arrange + var input = "{+baseurl}/users"; + var adapterWithBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + adapterWithBaseUrl.BaseUrl = "https://api.example.com/v2"; - // Act - var result = adapterWithBaseUrl.GetNormalizedHttpRoute(input); + // Act + var result = adapterWithBaseUrl.GetNormalizedHttpRoute(input); - // Assert - Assert.Equal("/users", result); + // Assert + Assert.Equal("/users", result); - // Cleanup - adapterWithBaseUrl.Dispose(); - } + // Cleanup + adapterWithBaseUrl.Dispose(); + } - [Fact] - public void GetNormalizedHttpRoute_WithNullBaseUrl_OnlyRemovesPlaceholder() - { - // Arrange - var input = "{+baseurl}/users/{id}"; - var adapterWithoutBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + [Fact] + public void GetNormalizedHttpRoute_WithNullBaseUrl_OnlyRemovesPlaceholder() + { + // Arrange + var input = "{+baseurl}/users/{id}"; + var adapterWithoutBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); - // Act - var result = adapterWithoutBaseUrl.GetNormalizedHttpRoute(input); + // Act + var result = adapterWithoutBaseUrl.GetNormalizedHttpRoute(input); - // Assert - Assert.Equal("/users/{id}", result); + // Assert + Assert.Equal("/users/{id}", result); - // Cleanup - adapterWithoutBaseUrl.Dispose(); - } + // Cleanup + adapterWithoutBaseUrl.Dispose(); + } - #endregion + #endregion - #region Activity Span Tag Tests + #region Activity Span Tag Tests - [Fact] - public async Task SendAsync_CreatesActivityWithHttpRouteTag() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + [Fact] + public async Task SendAsync_CreatesActivityWithHttpRouteTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users/{user%2Did}" - }; - requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); - // Clear any previously captured activities - _capturedActivities.Clear(); + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users/{user%2Did}" + }; + requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); - // Assert - var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); - Assert.NotNull(activity); + // Clear any previously captured activities + _capturedActivities.Clear(); - var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); - Assert.NotNull(httpRouteTag.Key); - Assert.Equal("/users/{user-id}", httpRouteTag.Value); - } + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - [Fact] - public async Task SendAsync_CreatesActivityWithUriTemplateTag() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + // Assert + var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); + Assert.NotNull(activity); + + var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); + Assert.NotNull(httpRouteTag.Key); + Assert.Equal("/users/{user-id}", httpRouteTag.Value); + } + + [Fact] + public async Task SendAsync_CreatesActivityWithUriTemplateTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users/{user%2Did}/messages" - }; - requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); - // Clear any previously captured activities - _capturedActivities.Clear(); + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users/{user%2Did}/messages" + }; + requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); - // Assert - var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); - Assert.NotNull(activity); + // Clear any previously captured activities + _capturedActivities.Clear(); - var uriTemplateTag = activity.Tags.FirstOrDefault(t => t.Key == "url.uri_template"); - Assert.NotNull(uriTemplateTag.Key); - Assert.Contains("/users/{user-id}/messages", uriTemplateTag.Value); - } + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - [Fact] - public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + // Assert + var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); + Assert.NotNull(activity); + + var uriTemplateTag = activity.Tags.FirstOrDefault(t => t.Key == "url.uri_template"); + Assert.NotNull(uriTemplateTag.Key); + Assert.Contains("/users/{user-id}/messages", uriTemplateTag.Value); + } + + [Fact] + public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}" - }; + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Clear any previously captured activities - _capturedActivities.Clear(); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}" + }; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + // Clear any previously captured activities + _capturedActivities.Clear(); - // Assert - var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); - Assert.NotNull(activity); + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); - Assert.NotNull(httpRouteTag.Key); - Assert.Equal("/", httpRouteTag.Value); - } + // Assert + var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); + Assert.NotNull(activity); - [Fact] - public async Task SendAsync_SetsHttpRequestMethodTag() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); + Assert.NotNull(httpRouteTag.Key); + Assert.Equal("/", httpRouteTag.Value); + } + + [Fact] + public async Task SendAsync_SetsHttpRequestMethodTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.POST, - UrlTemplate = "{+baseurl}/users" - }; + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); - _capturedActivities.Clear(); + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + var requestInfo = new RequestInformation + { + HttpMethod = Method.POST, + UrlTemplate = "{+baseurl}/users" + }; - // Assert - Assert.NotEmpty(_capturedActivities); + _capturedActivities.Clear(); - var methodTag = GetTagFromActivities("http.request.method"); - Assert.NotNull(methodTag.Key); - Assert.Equal("POST", methodTag.Value); - } + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - [Fact] - public async Task SendAsync_SetsUrlSchemeAndServerAddressTags() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + // Assert + Assert.NotEmpty(_capturedActivities); + + var methodTag = GetTagFromActivities("http.request.method"); + Assert.NotNull(methodTag.Key); + Assert.Equal("POST", methodTag.Value); + } + + [Fact] + public async Task SendAsync_SetsUrlSchemeAndServerAddressTags() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users" - }; + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - _capturedActivities.Clear(); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users" + }; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + _capturedActivities.Clear(); - // Assert - Assert.NotEmpty(_capturedActivities); + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - var schemeTag = GetTagFromActivities("url.scheme"); - Assert.Equal("https", schemeTag.Value); + // Assert + Assert.NotEmpty(_capturedActivities); - var serverTag = GetTagFromActivities("server.address"); - Assert.Equal("graph.microsoft.com", serverTag.Value); - } + var schemeTag = GetTagFromActivities("url.scheme"); + Assert.Equal("https", schemeTag.Value); - [Fact] - public async Task SendAsync_WithoutIncludeEUIIAttributes_DoesNotSetUrlFullTag() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions { IncludeEUIIAttributes = false }; - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + var serverTag = GetTagFromActivities("server.address"); + Assert.Equal("graph.microsoft.com", serverTag.Value); + } + + [Fact] + public async Task SendAsync_WithoutIncludeEUIIAttributes_DoesNotSetUrlFullTag() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users" - }; + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); - _capturedActivities.Clear(); + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions { IncludeEUIIAttributes = false }; + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users" + }; - // Assert - Assert.NotEmpty(_capturedActivities); + _capturedActivities.Clear(); - var urlFullTag = GetTagFromActivities("url.full"); - Assert.Null(urlFullTag.Key); - } + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - [Fact] - public async Task SendAsync_CreatesNestedActivitySpans() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation + // Assert + Assert.NotEmpty(_capturedActivities); + + var urlFullTag = GetTagFromActivities("url.full"); + Assert.Null(urlFullTag.Key); + } + + [Fact] + public async Task SendAsync_CreatesNestedActivitySpans() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users" - }; + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }); - _capturedActivities.Clear(); + var httpClient = new HttpClient(mockHandler.Object); + var authProvider = new AnonymousAuthenticationProvider(); + var observabilityOptions = new ObservabilityOptions(); + using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); + adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/users" + }; + + _capturedActivities.Clear(); - // Assert - Verify various nested spans are created - var resultingActivities = new HashSet(_capturedActivities.Select(static a => a.OperationName), StringComparer.Ordinal); - Assert.Contains("SendAsync - {+baseurl}/users", resultingActivities); - Assert.Contains("GetHttpResponseMessageAsync", resultingActivities); - Assert.Contains("GetRequestMessageFromRequestInformation", resultingActivities); - Assert.Contains("GetRootParseNodeAsync", resultingActivities); - } + // Act + await adapter.SendAsync(requestInfo, MockEntity.Factory); - #endregion + // Assert - Verify various nested spans are created + var resultingActivities = new HashSet(_capturedActivities.Select(static a => a.OperationName), StringComparer.Ordinal); + Assert.Contains("SendAsync - {+baseurl}/users", resultingActivities); + Assert.Contains("GetHttpResponseMessageAsync", resultingActivities); + Assert.Contains("GetRequestMessageFromRequestInformation", resultingActivities); + Assert.Contains("GetRootParseNodeAsync", resultingActivities); } + + #endregion } From c0dde22812494ac4ad4bab8d2d891f844cc2364a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:13:18 -0500 Subject: [PATCH 17/19] Mark IDisposable test classes as sealed (#649) * Initial plan * refactor: mark IDisposable test classes as sealed Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs | 2 +- tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs | 2 +- tests/http/httpClient/Middleware/CompressionHandlerTests.cs | 2 +- .../http/httpClient/Middleware/HeadersInspectionHandlerTests.cs | 2 +- tests/http/httpClient/Middleware/RedirectHandlerTests.cs | 2 +- tests/http/httpClient/Middleware/RetryHandlerTests.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs index e4c5e8cb..01b8ca7c 100644 --- a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -11,7 +11,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware { - public class AuthorizationHandlerTests : IDisposable + public sealed class AuthorizationHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; private const string _expectedAccessToken = "token"; diff --git a/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs b/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs index de07f3c5..149695c0 100644 --- a/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs +++ b/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs @@ -7,7 +7,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware; -public class BodyInspectionHandlerTests : IDisposable +public sealed class BodyInspectionHandlerTests : IDisposable { private readonly List _disposables = []; diff --git a/tests/http/httpClient/Middleware/CompressionHandlerTests.cs b/tests/http/httpClient/Middleware/CompressionHandlerTests.cs index 61ad60fe..0bfb08d5 100644 --- a/tests/http/httpClient/Middleware/CompressionHandlerTests.cs +++ b/tests/http/httpClient/Middleware/CompressionHandlerTests.cs @@ -13,7 +13,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware { [Obsolete("kiota clients now rely on the HttpClientHandler to handle decompression")] - public class CompressionHandlerTests : IDisposable + public sealed class CompressionHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; private readonly CompressionHandler _compressionHandler; diff --git a/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs b/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs index 8cb7d78f..218e40c2 100644 --- a/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs +++ b/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs @@ -10,7 +10,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware; -public class HeadersInspectionHandlerTests : IDisposable +public sealed class HeadersInspectionHandlerTests : IDisposable { private readonly List _disposables = new(); [Fact] diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 513c32b8..3208076c 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -11,7 +11,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware { - public class RedirectHandlerTests : IDisposable + public sealed class RedirectHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; private readonly RedirectHandler _redirectHandler; diff --git a/tests/http/httpClient/Middleware/RetryHandlerTests.cs b/tests/http/httpClient/Middleware/RetryHandlerTests.cs index fd233520..12339ad2 100644 --- a/tests/http/httpClient/Middleware/RetryHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RetryHandlerTests.cs @@ -16,7 +16,7 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware { - public class RetryHandlerTests : IDisposable + public sealed class RetryHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; private readonly RetryHandler _retryHandler; From 0635ea97e5e8dea16f9751af7c8a840fbf7b7349 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 4 Feb 2026 13:15:11 -0500 Subject: [PATCH 18/19] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/http/httpClient/HttpClientRequestAdapter.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs index 9f59d3ff..346b381e 100644 --- a/src/http/httpClient/HttpClientRequestAdapter.cs +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -139,11 +139,18 @@ internal string GetNormalizedHttpRoute(string telemetryPathValue) var placeholderIndex = span.IndexOf(baseUrlPlaceholder.AsSpan(), StringComparison.OrdinalIgnoreCase); if(placeholderIndex >= 0) { - // Concatenate parts around the placeholder - reduces allocations by using spans - var withoutPlaceholder = string.Concat( - new string(span[..placeholderIndex]), - new string(span[(placeholderIndex + baseUrlPlaceholder.Length)..]) - ); + // Build the string without the placeholder in a single allocation + var withoutPlaceholder = string.Create( + telemetryPathValue.Length - baseUrlPlaceholder.Length, + (telemetryPathValue, placeholderIndex, baseUrlPlaceholder.Length), + static (destination, state) => + { + var (source, index, length) = state; + // Copy the part before the placeholder + source.AsSpan(0, index).CopyTo(destination); + // Copy the part after the placeholder + source.AsSpan(index + length).CopyTo(destination[index..]); + }); span = withoutPlaceholder.AsSpan(); } From 0a7498569158dd61b301478beb8d0667b2126a37 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Thu, 5 Feb 2026 18:54:15 -0800 Subject: [PATCH 19/19] cleaned up tests to theories and fixed missing query string cases --- .../httpClient/HttpClientRequestAdapter.cs | 6 + ...pClientRequestAdapterObservabilityTests.cs | 394 +++--------------- 2 files changed, 75 insertions(+), 325 deletions(-) diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs index 346b381e..078bb30a 100644 --- a/src/http/httpClient/HttpClientRequestAdapter.cs +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -112,6 +112,12 @@ public string? BaseUrl { var decodedUriTemplate = ParametersNameDecodingHandler.DecodeUriEncodedString(requestInfo.UrlTemplate, charactersToDecodeForUriTemplate); var telemetryPathValue = string.IsNullOrEmpty(decodedUriTemplate) ? string.Empty : queryParametersCleanupRegex.Replace(decodedUriTemplate, string.Empty); + // Also strip literal query strings (e.g., ?@id={@id}) + var questionMarkIndex = telemetryPathValue.IndexOf('?'); + if(questionMarkIndex >= 0) + { + telemetryPathValue = telemetryPathValue.Substring(0, questionMarkIndex); + } var span = activitySource?.StartActivity($"{methodName} - {telemetryPathValue}"); span?.SetTag("url.uri_template", decodedUriTemplate); if(!string.IsNullOrEmpty(telemetryPathValue)) diff --git a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs index 90124b33..3bfc5b2b 100644 --- a/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs +++ b/tests/http/httpClient/HttpClientRequestAdapterObservabilityTests.cs @@ -107,281 +107,50 @@ public void Dispose() #region GetNormalizedHttpRoute Tests - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPlaceholder_RemovesPlaceholder() - { - // Arrange - var input = "{+baseurl}/users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderCaseInsensitive_RemovesPlaceholder() + [Theory] + [InlineData("{+baseurl}/users/{id}", "/users/{id}")] + [InlineData("{+BASEURL}/users/{id}", "/users/{id}")] + [InlineData("prefix{+baseurl}/users/{id}", "/prefix/users/{id}")] + [InlineData("https://graph.microsoft.com/v1.0/users/{id}", "/users/{id}")] + [InlineData("HTTPS://GRAPH.MICROSOFT.COM/V1.0/users/{id}", "/users/{id}")] + [InlineData("/users/{id}", "/users/{id}")] + [InlineData("users/{id}", "/users/{id}")] + [InlineData("///users/{id}", "/users/{id}")] + [InlineData(" users/{id}", "/users/{id}")] + [InlineData("users/{id} ", "/users/{id}")] + [InlineData(" users/{id} ", "/users/{id}")] + [InlineData("", "/")] + [InlineData(" ", "/")] + [InlineData("{+baseurl}", "/")] + [InlineData("https://graph.microsoft.com/v1.0", "/")] + [InlineData("https://graph.microsoft.com/v1.0/", "/")] + [InlineData("{+baseurl}/users/{user-id}/messages/{message-id}/attachments", "/users/{user-id}/messages/{message-id}/attachments")] + public void GetNormalizedHttpRoute_NormalizesCorrectly(string input, string expected) { - // Arrange - var input = "{+BASEURL}/users/{id}"; - // Act var result = _requestAdapter.GetNormalizedHttpRoute(input); // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPlaceholderInMiddle_RemovesPlaceholder() - { - // Arrange - var input = "prefix{+baseurl}/users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/prefix/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPrefix_RemovesPrefix() - { - // Arrange - var input = "https://graph.microsoft.com/v1.0/users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlPrefixCaseInsensitive_RemovesPrefix() - { - // Arrange - var input = "HTTPS://GRAPH.MICROSOFT.COM/V1.0/users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithLeadingSlash_PreservesSlash() - { - // Arrange - var input = "/users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithoutLeadingSlash_AddsSlash() - { - // Arrange - var input = "users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithMultipleLeadingSlashes_NormalizesToSingleSlash() - { - // Arrange - var input = "///users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithLeadingWhitespace_TrimsAndAddsSlash() - { - // Arrange - var input = " users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithTrailingWhitespace_TrimsWhitespace() - { - // Arrange - var input = "users/{id} "; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithLeadingAndTrailingWhitespace_TrimsWhitespace() - { - // Arrange - var input = " users/{id} "; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithEmptyString_ReturnsRootSlash() - { - // Arrange - var input = ""; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithOnlyWhitespace_ReturnsRootSlash() - { - // Arrange - var input = " "; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithOnlyBaseUrlPlaceholder_ReturnsRootSlash() - { - // Arrange - var input = "{+baseurl}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithOnlyBaseUrl_ReturnsRootSlash() - { - // Arrange - var input = "https://graph.microsoft.com/v1.0"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithBaseUrlAndTrailingSlash_ReturnsRootSlash() - { - // Arrange - var input = "https://graph.microsoft.com/v1.0/"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithComplexPath_NormalizesCorrectly() - { - // Arrange - var input = "{+baseurl}/users/{user-id}/messages/{message-id}/attachments"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{user-id}/messages/{message-id}/attachments", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithQueryParameters_PreservesPath() - { - // Arrange - Note: query parameters should be removed before calling this method - var input = "/users/{id}"; - - // Act - var result = _requestAdapter.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - } - - [Fact] - public void GetNormalizedHttpRoute_WithBothPlaceholderAndBaseUrl_HandlesBothTransformations() - { - // Arrange - var input = "{+baseurl}/users"; - var adapterWithBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); - adapterWithBaseUrl.BaseUrl = "https://api.example.com/v2"; - - // Act - var result = adapterWithBaseUrl.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users", result); - - // Cleanup - adapterWithBaseUrl.Dispose(); - } - - [Fact] - public void GetNormalizedHttpRoute_WithNullBaseUrl_OnlyRemovesPlaceholder() - { - // Arrange - var input = "{+baseurl}/users/{id}"; - var adapterWithoutBaseUrl = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); - - // Act - var result = adapterWithoutBaseUrl.GetNormalizedHttpRoute(input); - - // Assert - Assert.Equal("/users/{id}", result); - - // Cleanup - adapterWithoutBaseUrl.Dispose(); + Assert.Equal(expected, result); } #endregion #region Activity Span Tag Tests - [Fact] - public async Task SendAsync_CreatesActivityWithHttpRouteTag() + [Theory] + [InlineData("{+baseurl}/users/{user%2Did}", "user%2Did", "john@contoso.com", "/users/{user-id}")] + [InlineData("{+baseurl}", null, null, "/")] + [InlineData("{+baseurl}/users/{id}{?%24select,%24expand}", "id", "123", "/users/{id}")] + [InlineData("{+baseurl}/users{?%24filter}", null, null, "/users")] + [InlineData("{+baseurl}/users/{user%2Did}/messages{?%24top,%24skip}", "user%2Did", "alice@contoso.com", "/users/{user-id}/messages")] + [InlineData("{+baseurl}/me/drive/items/{item%2Did}{?%24select}", "item%2Did", "item123", "/me/drive/items/{item-id}")] + [InlineData("{+baseurl}/groups/{group%2Did}/members{?%24count}", "group%2Did", "group456", "/groups/{group-id}/members")] + [InlineData("{+baseurl}/groups/{group%2Did}/members/$ref?@id={%40id}", "group%2Did", "group456", "/groups/{group-id}/members/$ref")] + [InlineData("{+baseurl}/groups/{group%2Did}/members/$ref{?%24count,%24filter}", "group%2Did", "group456", "/groups/{group-id}/members/$ref")] + [InlineData("{+baseurl}/users/{id}{?%24filter,q}", "id", "789", "/users/{id}")] + [InlineData("{+baseurl}/drive/items/{item%2Did}/workbook/worksheets/{worksheet%2Did}/range(address='{address}'){?%24select,%24expand,%24top}", "item%2Did", "book123", "/drive/items/{item-id}/workbook/worksheets/{worksheet-id}/range(address='{address}')")] + public async Task SendAsync_CreatesActivityWithHttpRouteTag(string urlTemplate, string? pathParamKey, string? pathParamValue, string expectedRoute) { // Arrange var mockHandler = new Mock(); @@ -401,13 +170,12 @@ public async Task SendAsync_CreatesActivityWithHttpRouteTag() using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - var requestInfo = new RequestInformation + var pathParameters = new Dictionary(); + if(pathParamKey is not null && pathParamValue is not null) { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users/{user%2Did}" - }; - requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); - + pathParameters.Add(pathParamKey, pathParamValue); + } + var requestInfo = new RequestInformation(Method.GET, urlTemplate, pathParameters); // Clear any previously captured activities _capturedActivities.Clear(); @@ -420,11 +188,22 @@ public async Task SendAsync_CreatesActivityWithHttpRouteTag() var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); Assert.NotNull(httpRouteTag.Key); - Assert.Equal("/users/{user-id}", httpRouteTag.Value); + Assert.Equal(expectedRoute, httpRouteTag.Value); } - [Fact] - public async Task SendAsync_CreatesActivityWithUriTemplateTag() + [Theory] + [InlineData("{+baseurl}/users/{user%2Did}", "user%2Did", "john@contoso.com", "/users/{user-id}")] + [InlineData("{+baseurl}", null, null, "{+baseurl}")] + [InlineData("{+baseurl}/users/{id}{?%24select,%24expand}", "id", "123", "/users/{id}{?$select,$expand}")] + [InlineData("{+baseurl}/users{?%24filter}", null, null, "/users{?$filter}")] + [InlineData("{+baseurl}/users/{user%2Did}/messages{?%24top,%24skip}", "user%2Did", "alice@contoso.com", "/users/{user-id}/messages{?$top,$skip}")] + [InlineData("{+baseurl}/me/drive/items/{item%2Did}{?%24select}", "item%2Did", "item123", "/me/drive/items/{item-id}{?$select}")] + [InlineData("{+baseurl}/groups/{group%2Did}/members{?%24count}", "group%2Did", "group456", "/groups/{group-id}/members{?$count}")] + [InlineData("{+baseurl}/groups/{group%2Did}/members/$ref?@id={%40id}", "group%2Did", "group456", "members/$ref")] + [InlineData("{+baseurl}/groups/{group%2Did}/members/$ref{?%24count,%24filter}", "group%2Did", "group456", "/groups/{group-id}/members/$ref{?$count,$filter}")] + [InlineData("{+baseurl}/users/{id}{?%24filter,q}", "id", "789", "/users/{id}{?$filter,q}")] + [InlineData("{+baseurl}/drive/items/{item%2Did}/workbook/worksheets/{worksheet%2Did}/range(address='{address}'){?%24select,%24expand,%24top}", "item%2Did", "book123", "workbook/worksheets")] + public async Task SendAsync_CreatesActivityWithUriTemplateTag(string urlTemplate, string? pathParamKey, string? pathParamValue, string expectedSubstring) { // Arrange var mockHandler = new Mock(); @@ -444,12 +223,12 @@ public async Task SendAsync_CreatesActivityWithUriTemplateTag() using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - var requestInfo = new RequestInformation + var pathParameters = new Dictionary(); + if(pathParamKey is not null && pathParamValue is not null) { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}/users/{user%2Did}/messages" - }; - requestInfo.PathParameters.Add("user%2Did", "john@contoso.com"); + pathParameters.Add(pathParamKey, pathParamValue); + } + var requestInfo = new RequestInformation(Method.GET, urlTemplate, pathParameters); // Clear any previously captured activities _capturedActivities.Clear(); @@ -463,53 +242,18 @@ public async Task SendAsync_CreatesActivityWithUriTemplateTag() var uriTemplateTag = activity.Tags.FirstOrDefault(t => t.Key == "url.uri_template"); Assert.NotNull(uriTemplateTag.Key); - Assert.Contains("/users/{user-id}/messages", uriTemplateTag.Value); - } - - [Fact] - public async Task SendAsync_WithEmptyPath_SetsHttpRouteToRoot() - { - // Arrange - var mockHandler = new Mock(); - mockHandler.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") - }); - - var httpClient = new HttpClient(mockHandler.Object); - var authProvider = new AnonymousAuthenticationProvider(); - var observabilityOptions = new ObservabilityOptions(); - using var adapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient, observabilityOptions: observabilityOptions); - adapter.BaseUrl = "https://graph.microsoft.com/v1.0"; - - var requestInfo = new RequestInformation - { - HttpMethod = Method.GET, - UrlTemplate = "{+baseurl}" - }; - - // Clear any previously captured activities - _capturedActivities.Clear(); - - // Act - await adapter.SendAsync(requestInfo, MockEntity.Factory); - - // Assert - var activity = _capturedActivities.FirstOrDefault(a => a.OperationName.Contains("SendAsync")); - Assert.NotNull(activity); - - var httpRouteTag = activity.Tags.FirstOrDefault(t => t.Key == "http.route"); - Assert.NotNull(httpRouteTag.Key); - Assert.Equal("/", httpRouteTag.Value); + Assert.Contains(expectedSubstring, uriTemplateTag.Value); } - [Fact] - public async Task SendAsync_SetsHttpRequestMethodTag() + [Theory] + [InlineData(Method.GET, "GET")] + [InlineData(Method.POST, "POST")] + [InlineData(Method.PUT, "PUT")] + [InlineData(Method.PATCH, "PATCH")] + [InlineData(Method.DELETE, "DELETE")] + [InlineData(Method.HEAD, "HEAD")] + [InlineData(Method.OPTIONS, "OPTIONS")] + public async Task SendAsync_SetsHttpRequestMethodTag(Method httpMethod, string expectedMethodTag) { // Arrange var mockHandler = new Mock(); @@ -531,7 +275,7 @@ public async Task SendAsync_SetsHttpRequestMethodTag() var requestInfo = new RequestInformation { - HttpMethod = Method.POST, + HttpMethod = httpMethod, UrlTemplate = "{+baseurl}/users" }; @@ -545,7 +289,7 @@ public async Task SendAsync_SetsHttpRequestMethodTag() var methodTag = GetTagFromActivities("http.request.method"); Assert.NotNull(methodTag.Key); - Assert.Equal("POST", methodTag.Value); + Assert.Equal(expectedMethodTag, methodTag.Value); } [Fact]