// // Copyright The OpenTelemetry Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Moq; using Newtonsoft.Json; using OpenTelemetry.Context.Propagation; using OpenTelemetry.Instrumentation.AspNetCore.Implementation; using OpenTelemetry.Tests; using OpenTelemetry.Trace; #if NETCOREAPP3_1 using TestApp.AspNetCore._3._1; #endif #if NET6_0 using TestApp.AspNetCore._6._0; #endif using Xunit; namespace OpenTelemetry.Instrumentation.AspNetCore.Tests { // See https://github.com/aspnet/Docs/tree/master/aspnetcore/test/integration-tests/samples/2.x/IntegrationTestsSample public sealed class BasicTests : IClassFixture>, IDisposable { private readonly WebApplicationFactory factory; private TracerProvider tracerProvider = null; public BasicTests(WebApplicationFactory factory) { this.factory = factory; } [Fact] public void AddAspNetCoreInstrumentation_BadArgs() { TracerProviderBuilder builder = null; Assert.Throws(() => builder.AddAspNetCoreInstrumentation()); } [Fact] public async Task StatusIsUnsetOn200Response() { var activityProcessor = new Mock>(); void ConfigureTestServices(IServiceCollection services) { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation() .AddProcessor(activityProcessor.Object) .Build(); } // Arrange using (var client = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureTestServices)) .CreateClient()) { // Act var response = await client.GetAsync("/api/values"); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 WaitForProcessorInvocations(activityProcessor, 3); } Assert.Equal(3, activityProcessor.Invocations.Count); // begin and end was called // we should only call Processor.OnEnd for the "/api/values" request Assert.Single(activityProcessor.Invocations, invo => invo.Method.Name == "OnEnd"); var activity = activityProcessor.Invocations.FirstOrDefault(invo => invo.Method.Name == "OnEnd").Arguments[0] as Activity; Assert.Equal(200, activity.GetTagValue(SemanticConventions.AttributeHttpStatusCode)); var status = activity.GetStatus(); Assert.Equal(status, Status.Unset); ValidateAspNetCoreActivity(activity, "/api/values"); } [Theory] [InlineData(true)] [InlineData(false)] public async Task SuccessfulTemplateControllerCallGeneratesASpan(bool shouldEnrich) { var activityProcessor = new Mock>(); activityProcessor.Setup(x => x.OnStart(It.IsAny())).Callback(c => c.SetTag("enriched", "no")); void ConfigureTestServices(IServiceCollection services) { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation(options => { if (shouldEnrich) { options.Enrich = ActivityEnrichment; } }) .AddProcessor(activityProcessor.Object) .Build(); } // Arrange using (var client = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureTestServices)) .CreateClient()) { // Act var response = await client.GetAsync("/api/values"); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 WaitForProcessorInvocations(activityProcessor, 3); } Assert.Equal(3, activityProcessor.Invocations.Count); // begin and end was called var activity = (Activity)activityProcessor.Invocations[2].Arguments[0]; Assert.NotEmpty(activity.Tags.Where(tag => tag.Key == "enriched")); Assert.Equal(shouldEnrich ? "yes" : "no", activity.Tags.Where(tag => tag.Key == "enriched").FirstOrDefault().Value); ValidateAspNetCoreActivity(activity, "/api/values"); } [Fact] public async Task SuccessfulTemplateControllerCallUsesParentContext() { var activityProcessor = new Mock>(); var expectedTraceId = ActivityTraceId.CreateRandom(); var expectedSpanId = ActivitySpanId.CreateRandom(); // Arrange using (var testFactory = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(services => { this.tracerProvider = Sdk.CreateTracerProviderBuilder().AddAspNetCoreInstrumentation() .AddProcessor(activityProcessor.Object) .Build(); }))) { using var client = testFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/api/values/2"); request.Headers.Add("traceparent", $"00-{expectedTraceId}-{expectedSpanId}-01"); // Act var response = await client.SendAsync(request); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 WaitForProcessorInvocations(activityProcessor, 3); } // List of invocations // 1. SetParentProvider for TracerProviderSdk // 2. OnStart for the activity created by AspNetCore with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // 3. OnStart for the sibling activity created by the instrumentation library with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn and the first tag that is added is (IsCreatedByInstrumentation, bool.TrueString) // 4. OnEnd for the sibling activity created by the instrumentation library with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn and the first tag that is added is (IsCreatedByInstrumentation, bool.TrueString) // we should only call Processor.OnEnd once for the sibling activity Assert.Single(activityProcessor.Invocations, invo => invo.Method.Name == "OnEnd"); var activity = activityProcessor.Invocations.FirstOrDefault(invo => invo.Method.Name == "OnEnd").Arguments[0] as Activity; Assert.Equal("Microsoft.AspNetCore.Hosting.HttpRequestIn", activity.OperationName); Assert.Equal("api/Values/{id}", activity.DisplayName); Assert.Equal(expectedTraceId, activity.Context.TraceId); Assert.Equal(expectedSpanId, activity.ParentSpanId); ValidateAspNetCoreActivity(activity, "/api/values/2"); } [Fact] public async Task CustomPropagator() { try { var activityProcessor = new Mock>(); var expectedTraceId = ActivityTraceId.CreateRandom(); var expectedSpanId = ActivitySpanId.CreateRandom(); var propagator = new Mock(); propagator.Setup(m => m.Extract(It.IsAny(), It.IsAny(), It.IsAny>>())).Returns( new PropagationContext( new ActivityContext( expectedTraceId, expectedSpanId, ActivityTraceFlags.Recorded), default)); // Arrange using (var testFactory = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(services => { Sdk.SetDefaultTextMapPropagator(propagator.Object); this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation() .AddProcessor(activityProcessor.Object) .Build(); }))) { using var client = testFactory.CreateClient(); var response = await client.GetAsync("/api/values/2"); response.EnsureSuccessStatusCode(); // Status Code 200-299 WaitForProcessorInvocations(activityProcessor, 4); } // List of invocations on the processor // 1. SetParentProvider for TracerProviderSdk // 2. OnStart for the activity created by AspNetCore with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // 3. OnStart for the sibling activity created by the instrumentation library with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn and the first tag that is added is (IsCreatedByInstrumentation, bool.TrueString) // 4. OnEnd for the sibling activity created by the instrumentation library with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn and the first tag that is added is (IsCreatedByInstrumentation, bool.TrueString) Assert.Equal(4, activityProcessor.Invocations.Count); var startedActivities = activityProcessor.Invocations.Where(invo => invo.Method.Name == "OnStart"); var stoppedActivities = activityProcessor.Invocations.Where(invo => invo.Method.Name == "OnEnd"); Assert.Equal(2, startedActivities.Count()); Assert.Single(stoppedActivities); // The activity created by the framework and the sibling activity are both sent to Processor.OnStart Assert.Equal(2, startedActivities.Count(item => { var startedActivity = item.Arguments[0] as Activity; return startedActivity.OperationName == HttpInListener.ActivityOperationName; })); // we should only call Processor.OnEnd once for the sibling activity Assert.Single(activityProcessor.Invocations, invo => invo.Method.Name == "OnEnd"); var activity = activityProcessor.Invocations.FirstOrDefault(invo => invo.Method.Name == "OnEnd").Arguments[0] as Activity; Assert.True(activity.Duration != TimeSpan.Zero); Assert.Equal("api/Values/{id}", activity.DisplayName); Assert.Equal(expectedTraceId, activity.Context.TraceId); Assert.Equal(expectedSpanId, activity.ParentSpanId); ValidateAspNetCoreActivity(activity, "/api/values/2"); } finally { Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator(new TextMapPropagator[] { new TraceContextPropagator(), new BaggagePropagator(), })); } } [Fact] public async Task RequestNotCollectedWhenFilterIsApplied() { var activityProcessor = new Mock>(); void ConfigureTestServices(IServiceCollection services) { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation((opt) => opt.Filter = (ctx) => ctx.Request.Path != "/api/values/2") .AddProcessor(activityProcessor.Object) .Build(); } // Arrange using (var testFactory = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureTestServices))) { using var client = testFactory.CreateClient(); // Act var response1 = await client.GetAsync("/api/values"); var response2 = await client.GetAsync("/api/values/2"); // Assert response1.EnsureSuccessStatusCode(); // Status Code 200-299 response2.EnsureSuccessStatusCode(); // Status Code 200-299 WaitForProcessorInvocations(activityProcessor, 4); } // 1. SetParentProvider for TracerProviderSdk // 2. OnStart for the activity created by AspNetCore for "/api/values" with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // 3. OnEnd for the activity created by AspNetCore for "/api/values" with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // 4. OnStart for the activity created by AspNetCore for "/api/values/2" with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn Assert.Equal(4, activityProcessor.Invocations.Count); // we should only call Processor.OnEnd for the "/api/values" request Assert.Single(activityProcessor.Invocations, invo => invo.Method.Name == "OnEnd"); var activity = activityProcessor.Invocations.FirstOrDefault(invo => invo.Method.Name == "OnEnd").Arguments[0] as Activity; ValidateAspNetCoreActivity(activity, "/api/values"); } [Fact] public async Task RequestNotCollectedWhenFilterThrowException() { var activityProcessor = new Mock>(); void ConfigureTestServices(IServiceCollection services) { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation((opt) => opt.Filter = (ctx) => { if (ctx.Request.Path == "/api/values/2") { throw new Exception("from InstrumentationFilter"); } else { return true; } }) .AddProcessor(activityProcessor.Object) .Build(); } // Arrange using (var testFactory = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureTestServices))) { using var client = testFactory.CreateClient(); // Act using (var inMemoryEventListener = new InMemoryEventListener(AspNetCoreInstrumentationEventSource.Log)) { var response1 = await client.GetAsync("/api/values"); var response2 = await client.GetAsync("/api/values/2"); response1.EnsureSuccessStatusCode(); // Status Code 200-299 response2.EnsureSuccessStatusCode(); // Status Code 200-299 Assert.Single(inMemoryEventListener.Events.Where((e) => e.EventId == 3)); } WaitForProcessorInvocations(activityProcessor, 3); } // As InstrumentationFilter threw, we continue as if the // InstrumentationFilter did not exist. // List of invocations on the processor // 1. SetParentProvider for TracerProviderSdk // 2. OnStart for the activity created by AspNetCore for "/api/values" with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // 3. OnEnd for the activity created by AspNetCore for "/api/values" with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // 4. OnStart for the activity created by AspNetCore for "/api/values/2" with the OperationName: Microsoft.AspNetCore.Hosting.HttpRequestIn // we should only call Processor.OnEnd for the "/api/values" request Assert.Single(activityProcessor.Invocations, invo => invo.Method.Name == "OnEnd"); var activity = activityProcessor.Invocations.FirstOrDefault(invo => invo.Method.Name == "OnEnd").Arguments[0] as Activity; ValidateAspNetCoreActivity(activity, "/api/values"); } [Theory] [InlineData(SamplingDecision.Drop)] [InlineData(SamplingDecision.RecordOnly)] [InlineData(SamplingDecision.RecordAndSample)] public async Task ExtractContextIrrespectiveOfSamplingDecision(SamplingDecision samplingDecision) { try { var expectedTraceId = ActivityTraceId.CreateRandom(); var expectedParentSpanId = ActivitySpanId.CreateRandom(); var expectedTraceState = "rojo=1,congo=2"; var activityContext = new ActivityContext(expectedTraceId, expectedParentSpanId, ActivityTraceFlags.Recorded, expectedTraceState); var expectedBaggage = Baggage.SetBaggage("key1", "value1").SetBaggage("key2", "value2"); Sdk.SetDefaultTextMapPropagator(new ExtractOnlyPropagator(activityContext, expectedBaggage)); // Arrange using var testFactory = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(services => { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .SetSampler(new TestSampler(samplingDecision)) .AddAspNetCoreInstrumentation() .Build(); })); using var client = testFactory.CreateClient(); // Test TraceContext Propagation var request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityTraceContext"); var response = await client.SendAsync(request); var childActivityTraceContext = JsonConvert.DeserializeObject>(response.Content.ReadAsStringAsync().Result); response.EnsureSuccessStatusCode(); Assert.Equal(expectedTraceId.ToString(), childActivityTraceContext["TraceId"]); Assert.Equal(expectedTraceState, childActivityTraceContext["TraceState"]); Assert.NotEqual(expectedParentSpanId.ToString(), childActivityTraceContext["ParentSpanId"]); // there is a new activity created in instrumentation therefore the ParentSpanId is different that what is provided in the headers // Test Baggage Context Propagation request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityBaggageContext"); response = await client.SendAsync(request); var childActivityBaggageContext = JsonConvert.DeserializeObject>(response.Content.ReadAsStringAsync().Result); response.EnsureSuccessStatusCode(); Assert.Single(childActivityBaggageContext, item => item.Key == "key1" && item.Value == "value1"); Assert.Single(childActivityBaggageContext, item => item.Key == "key2" && item.Value == "value2"); } finally { Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator(new TextMapPropagator[] { new TraceContextPropagator(), new BaggagePropagator(), })); } } [Fact] public async Task ExtractContextIrrespectiveOfTheFilterApplied() { try { var expectedTraceId = ActivityTraceId.CreateRandom(); var expectedParentSpanId = ActivitySpanId.CreateRandom(); var expectedTraceState = "rojo=1,congo=2"; var activityContext = new ActivityContext(expectedTraceId, expectedParentSpanId, ActivityTraceFlags.Recorded, expectedTraceState); var expectedBaggage = Baggage.SetBaggage("key1", "value1").SetBaggage("key2", "value2"); Sdk.SetDefaultTextMapPropagator(new ExtractOnlyPropagator(activityContext, expectedBaggage)); // Arrange bool isFilterCalled = false; using var testFactory = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(services => { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation(options => { options.Filter = context => { isFilterCalled = true; return false; }; }) .Build(); })); using var client = testFactory.CreateClient(); // Test TraceContext Propagation var request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityTraceContext"); var response = await client.SendAsync(request); // Ensure that filter was called Assert.True(isFilterCalled); var childActivityTraceContext = JsonConvert.DeserializeObject>(response.Content.ReadAsStringAsync().Result); response.EnsureSuccessStatusCode(); Assert.Equal(expectedTraceId.ToString(), childActivityTraceContext["TraceId"]); Assert.Equal(expectedTraceState, childActivityTraceContext["TraceState"]); Assert.NotEqual(expectedParentSpanId.ToString(), childActivityTraceContext["ParentSpanId"]); // there is a new activity created in instrumentation therefore the ParentSpanId is different that what is provided in the headers // Test Baggage Context Propagation request = new HttpRequestMessage(HttpMethod.Get, "/api/GetChildActivityBaggageContext"); response = await client.SendAsync(request); var childActivityBaggageContext = JsonConvert.DeserializeObject>(response.Content.ReadAsStringAsync().Result); response.EnsureSuccessStatusCode(); Assert.Single(childActivityBaggageContext, item => item.Key == "key1" && item.Value == "value1"); Assert.Single(childActivityBaggageContext, item => item.Key == "key2" && item.Value == "value2"); } finally { Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator(new TextMapPropagator[] { new TraceContextPropagator(), new BaggagePropagator(), })); } } [Fact] public async Task BaggageClearedWhenActivityStopped() { int? baggageCountAfterStart = null; int? baggageCountAfterStop = null; using EventWaitHandle stopSignal = new EventWaitHandle(false, EventResetMode.ManualReset); void ConfigureTestServices(IServiceCollection services) { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .AddAspNetCoreInstrumentation(new AspNetCoreInstrumentation( new TestHttpInListener(new AspNetCoreInstrumentationOptions()) { OnStartActivityCallback = (activity, payload) => { baggageCountAfterStart = Baggage.Current.Count; }, OnStopActivityCallback = (activity, payload) => { baggageCountAfterStop = Baggage.Current.Count; stopSignal.Set(); }, })) .Build(); } // Arrange using (var client = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureTestServices)) .CreateClient()) { using var request = new HttpRequestMessage(HttpMethod.Get, "/api/values"); request.Headers.TryAddWithoutValidation("baggage", "TestKey1=123,TestKey2=456"); // Act using var response = await client.SendAsync(request); } stopSignal.WaitOne(5000); // Assert Assert.NotNull(baggageCountAfterStart); Assert.Equal(2, baggageCountAfterStart); Assert.NotNull(baggageCountAfterStop); Assert.Equal(0, baggageCountAfterStop); } [Theory] [InlineData(SamplingDecision.Drop, false, false)] [InlineData(SamplingDecision.RecordOnly, true, true)] [InlineData(SamplingDecision.RecordAndSample, true, true)] public async Task FilterAndEnrichAreOnlyCalledWhenSampled(SamplingDecision samplingDecision, bool shouldFilterBeCalled, bool shouldEnrichBeCalled) { bool filterCalled = false; bool enrichCalled = false; void ConfigureTestServices(IServiceCollection services) { this.tracerProvider = Sdk.CreateTracerProviderBuilder() .SetSampler(new TestSampler(samplingDecision)) .AddAspNetCoreInstrumentation(options => { options.Filter = (context) => { filterCalled = true; return true; }; options.Enrich = (activity, methodName, request) => { enrichCalled = true; }; }) .Build(); } // Arrange using var client = this.factory .WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureTestServices)) .CreateClient(); // Act var response = await client.GetAsync("/api/values"); // Assert Assert.Equal(shouldFilterBeCalled, filterCalled); Assert.Equal(shouldEnrichBeCalled, enrichCalled); } public void Dispose() { this.tracerProvider?.Dispose(); } private static void WaitForProcessorInvocations(Mock> activityProcessor, int invocationCount) { // We need to let End callback execute as it is executed AFTER response was returned. // In unit tests environment there may be a lot of parallel unit tests executed, so // giving some breezing room for the End callback to complete Assert.True(SpinWait.SpinUntil( () => { Thread.Sleep(10); return activityProcessor.Invocations.Count >= invocationCount; }, TimeSpan.FromSeconds(1))); } private static void ValidateAspNetCoreActivity(Activity activityToValidate, string expectedHttpPath) { Assert.Equal(ActivityKind.Server, activityToValidate.Kind); Assert.Equal(HttpInListener.ActivitySourceName, activityToValidate.Source.Name); Assert.Equal(HttpInListener.Version.ToString(), activityToValidate.Source.Version); Assert.Equal(expectedHttpPath, activityToValidate.GetTagValue(SemanticConventions.AttributeHttpTarget) as string); } private static void ActivityEnrichment(Activity activity, string method, object obj) { Assert.True(activity.IsAllDataRequested); switch (method) { case "OnStartActivity": Assert.True(obj is HttpRequest); break; case "OnStopActivity": Assert.True(obj is HttpResponse); break; default: break; } activity.SetTag("enriched", "yes"); } private class ExtractOnlyPropagator : TextMapPropagator { private readonly ActivityContext activityContext; private readonly Baggage baggage; public ExtractOnlyPropagator(ActivityContext activityContext, Baggage baggage) { this.activityContext = activityContext; this.baggage = baggage; } public override ISet Fields => throw new NotImplementedException(); public override PropagationContext Extract(PropagationContext context, T carrier, Func> getter) { return new PropagationContext(this.activityContext, this.baggage); } public override void Inject(PropagationContext context, T carrier, Action setter) { throw new NotImplementedException(); } } private class TestSampler : Sampler { private readonly SamplingDecision samplingDecision; public TestSampler(SamplingDecision samplingDecision) { this.samplingDecision = samplingDecision; } public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) { return new SamplingResult(this.samplingDecision); } } private class TestHttpInListener : HttpInListener { public Action OnStartActivityCallback; public Action OnStopActivityCallback; public TestHttpInListener(AspNetCoreInstrumentationOptions options) : base(options) { } public override void OnStartActivity(Activity activity, object payload) { base.OnStartActivity(activity, payload); this.OnStartActivityCallback?.Invoke(activity, payload); } public override void OnStopActivity(Activity activity, object payload) { base.OnStopActivity(activity, payload); this.OnStopActivityCallback?.Invoke(activity, payload); } } } }