From 104bf8381dc1adc04b91fb8c63678a271f7673a8 Mon Sep 17 00:00:00 2001 From: clempiq <104780066+clementpiquin@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:14:14 -1000 Subject: [PATCH 1/2] Add ASP.NET Core instrumentation to OpenTelemetry tracing Registers AddAspNetCoreInstrumentation() so every inbound REST, GraphQL and MCP request produces a server span and continues the incoming traceparent. The OpenTelemetry.Instrumentation.AspNetCore package was already referenced but unused. Closes #3559 --- .../Telemetry/OpenTelemetryTests.cs | 48 +++++++++++++++++++ src/Service/Startup.cs | 3 ++ 2 files changed, 51 insertions(+) diff --git a/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs b/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs index df213c6f48..f9503562a4 100644 --- a/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs @@ -2,7 +2,10 @@ // Licensed under the MIT License. using System; +using System.Diagnostics; using System.IO; +using System.Net.Http; +using System.Threading.Tasks; using Azure.DataApiBuilder.Config.ObjectModel; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -24,6 +27,7 @@ public class OpenTelemetryTests private const string CONFIG_WITH_TELEMETRY = "dab-open-telemetry-test-config.json"; private const string CONFIG_WITHOUT_TELEMETRY = "dab-no-open-telemetry-test-config.json"; + private const string ASPNETCORE_ACTIVITY_SOURCE_NAME = "Microsoft.AspNetCore"; private static RuntimeConfig _configuration; /// @@ -88,6 +92,50 @@ public void TestOpenTelemetryServicesEnabled() Assert.IsNotNull(meterProvider, "MeterProvider should be registered."); } + /// + /// Tests that an inbound HTTP request produces a recorded server span when Open Telemetry is enabled. + /// The listener below never samples by itself, so the span can only be recorded because + /// the OpenTelemetry TracerProvider subscribes to the ASP.NET Core activity source. + /// + [TestMethod] + public async Task TestOpenTelemetryRecordsInboundHttpRequestSpan() + { + // Arrange + SetUpTelemetryInConfig(CONFIG_WITH_TELEMETRY, true, "http://localhost:4317", "key=key", OtlpExportProtocol.Grpc); + + string requestPath = $"/otel-inbound-span-test-{Guid.NewGuid():N}"; + TaskCompletionSource recordedServerActivity = new(TaskCreationOptions.RunContinuationsAsynchronously); + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == ASPNETCORE_ACTIVITY_SOURCE_NAME, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.None, + ActivityStopped = activity => + { + if (activity.Recorded && activity.Kind == ActivityKind.Server && activity.GetTagItem("url.path") as string == requestPath) + { + recordedServerActivity.TrySetResult(activity); + } + } + }; + ActivitySource.AddActivityListener(listener); + + string[] args = new[] + { + $"--ConfigFileName={CONFIG_WITH_TELEMETRY}" + }; + using TestServer server = new(Program.CreateWebHostBuilder(args)); + Assert.IsNotNull(server.Services.GetService(), "TracerProvider should be registered."); + using HttpClient client = server.CreateClient(); + + // Act + await client.GetAsync(requestPath); + + // Assert + // The server activity is stopped once the request pipeline completes, which can happen after the response is returned. + Task completedTask = await Task.WhenAny(recordedServerActivity.Task, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.AreSame(recordedServerActivity.Task, completedTask, "An inbound HTTP request should produce a recorded server span."); + } + /// /// Tests if the services are correctly disabled for Open Telemetry. /// diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index b41550bf2e..2bd4f32069 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -192,6 +192,9 @@ public void ConfigureServices(IServiceCollection services) .WithTracing(tracing => { tracing.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!)) + // Creates a server span for every inbound HTTP request (REST, GraphQL, MCP, health) + // and continues the incoming W3C traceparent, so DAB spans are parented correctly. + .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() // TODO: should we also add FusionCache traces? // To do so we just need to add the package ZiggyCreatures.FusionCache.OpenTelemetry and call From efd025e4e9917a7cf9760f14fc1b01616658ce4e Mon Sep 17 00:00:00 2001 From: clempiq <104780066+clementpiquin@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:29:44 -1000 Subject: [PATCH 2/2] Assert W3C traceparent propagation in inbound span test --- .../Telemetry/OpenTelemetryTests.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs b/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs index f9503562a4..e1602517a0 100644 --- a/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs @@ -93,7 +93,8 @@ public void TestOpenTelemetryServicesEnabled() } /// - /// Tests that an inbound HTTP request produces a recorded server span when Open Telemetry is enabled. + /// Tests that an inbound HTTP request produces a recorded server span when Open Telemetry is enabled, + /// and that this span continues the trace context received in the W3C traceparent header. /// The listener below never samples by itself, so the span can only be recorded because /// the OpenTelemetry TracerProvider subscribes to the ASP.NET Core activity source. /// @@ -103,7 +104,8 @@ public async Task TestOpenTelemetryRecordsInboundHttpRequestSpan() // Arrange SetUpTelemetryInConfig(CONFIG_WITH_TELEMETRY, true, "http://localhost:4317", "key=key", OtlpExportProtocol.Grpc); - string requestPath = $"/otel-inbound-span-test-{Guid.NewGuid():N}"; + ActivityTraceId incomingTraceId = ActivityTraceId.CreateRandom(); + ActivitySpanId incomingParentSpanId = ActivitySpanId.CreateRandom(); TaskCompletionSource recordedServerActivity = new(TaskCreationOptions.RunContinuationsAsynchronously); using ActivityListener listener = new() { @@ -111,7 +113,7 @@ public async Task TestOpenTelemetryRecordsInboundHttpRequestSpan() Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.None, ActivityStopped = activity => { - if (activity.Recorded && activity.Kind == ActivityKind.Server && activity.GetTagItem("url.path") as string == requestPath) + if (activity.TraceId == incomingTraceId) { recordedServerActivity.TrySetResult(activity); } @@ -127,13 +129,22 @@ public async Task TestOpenTelemetryRecordsInboundHttpRequestSpan() Assert.IsNotNull(server.Services.GetService(), "TracerProvider should be registered."); using HttpClient client = server.CreateClient(); + using HttpRequestMessage request = new(HttpMethod.Get, "/"); + request.Headers.Add("traceparent", $"00-{incomingTraceId.ToHexString()}-{incomingParentSpanId.ToHexString()}-01"); + // Act - await client.GetAsync(requestPath); + using HttpResponseMessage response = await client.SendAsync(request); // Assert // The server activity is stopped once the request pipeline completes, which can happen after the response is returned. Task completedTask = await Task.WhenAny(recordedServerActivity.Task, Task.Delay(TimeSpan.FromSeconds(10))); - Assert.AreSame(recordedServerActivity.Task, completedTask, "An inbound HTTP request should produce a recorded server span."); + Assert.AreSame(recordedServerActivity.Task, completedTask, "An inbound HTTP request should produce a server span."); + + Activity serverActivity = await recordedServerActivity.Task; + Assert.IsTrue(serverActivity.Recorded, "The server span should be recorded."); + Assert.AreEqual(ActivityKind.Server, serverActivity.Kind, "The span should be a server span."); + Assert.AreEqual(incomingTraceId, serverActivity.TraceId, "The server span should continue the incoming trace."); + Assert.AreEqual(incomingParentSpanId, serverActivity.ParentSpanId, "The server span should be parented to the incoming span."); } ///