opentelemetry-dotnet/test/OpenTelemetry.Instrumentati.../HttpInListenerTests.cs

388 lines
16 KiB
C#

// <copyright file="HttpInListenerTests.cs" company="OpenTelemetry Authors">
// 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.
// </copyright>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Web;
using System.Web.Routing;
using Moq;
using OpenTelemetry.Context.Propagation;
using OpenTelemetry.Instrumentation.AspNet.Implementation;
using OpenTelemetry.Tests;
using OpenTelemetry.Trace;
using Xunit;
namespace OpenTelemetry.Instrumentation.AspNet.Tests
{
public class HttpInListenerTests : IDisposable
{
private readonly FakeAspNetDiagnosticSource fakeAspNetDiagnosticSource;
public HttpInListenerTests()
{
this.fakeAspNetDiagnosticSource = new FakeAspNetDiagnosticSource();
}
public void Dispose()
{
this.fakeAspNetDiagnosticSource.Dispose();
}
[Theory]
[InlineData("http://localhost/", 0, null, "TraceContext")]
[InlineData("http://localhost/", 0, null, "TraceContext", true)]
[InlineData("https://localhost/", 0, null, "TraceContext")]
[InlineData("http://localhost:443/", 0, null, "TraceContext")] // Test http over 443
[InlineData("https://localhost:80/", 0, null, "TraceContext")] // Test https over 80
[InlineData("http://localhost:80/Index", 1, "{controller}/{action}/{id}", "TraceContext")]
[InlineData("https://localhost:443/about_attr_route/10", 2, "about_attr_route/{customerId}", "TraceContext")]
[InlineData("http://localhost:1880/api/weatherforecast", 3, "api/{controller}/{id}", "TraceContext")]
[InlineData("https://localhost:1843/subroute/10", 4, "subroute/{customerId}", "TraceContext")]
[InlineData("http://localhost/api/value", 0, null, "TraceContext", false, "/api/value")] // Request will be filtered
[InlineData("http://localhost/api/value", 0, null, "TraceContext", false, "{ThrowException}")] // Filter user code will throw an exception
[InlineData("http://localhost/api/value/2", 0, null, "CustomContextMatchParent")]
[InlineData("http://localhost/api/value/2", 0, null, "CustomContextNonmatchParent")]
[InlineData("http://localhost/api/value/2", 0, null, "CustomContextNonmatchParent", false, null, true)]
public void AspNetRequestsAreCollectedSuccessfully(
string url,
int routeType,
string routeTemplate,
string carrierFormat,
bool setStatusToErrorInEnrich = false,
string filter = null,
bool restoreCurrentActivity = false)
{
IDisposable openTelemetry = null;
RouteData routeData;
switch (routeType)
{
case 0: // WebForm, no route data.
routeData = new RouteData();
break;
case 1: // Traditional MVC.
case 2: // Attribute routing MVC.
case 3: // Traditional WebAPI.
routeData = new RouteData()
{
Route = new Route(routeTemplate, null),
};
break;
case 4: // Attribute routing WebAPI.
routeData = new RouteData();
var value = new[]
{
new
{
Route = new
{
RouteTemplate = routeTemplate,
},
},
};
routeData.Values.Add(
"MS_SubRoutes",
value);
break;
default:
throw new NotSupportedException();
}
var workerRequest = new Mock<HttpWorkerRequest>();
workerRequest.Setup(wr => wr.GetKnownRequestHeader(It.IsAny<int>())).Returns<int>(i =>
{
return i switch
{
39 => "Test", // User-Agent
_ => null,
};
});
HttpContext.Current = new HttpContext(
new HttpRequest(string.Empty, url, string.Empty)
{
RequestContext = new RequestContext()
{
RouteData = routeData,
},
},
new HttpResponse(new StringWriter()));
typeof(HttpRequest).GetField("_wr", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(HttpContext.Current.Request, workerRequest.Object);
var expectedTraceId = ActivityTraceId.CreateRandom();
var expectedSpanId = ActivitySpanId.CreateRandom();
var propagator = new Mock<TextMapPropagator>();
propagator.Setup(m => m.Extract<HttpRequest>(It.IsAny<PropagationContext>(), It.IsAny<HttpRequest>(), It.IsAny<Func<HttpRequest, string, IEnumerable<string>>>())).Returns(new PropagationContext(
new ActivityContext(
expectedTraceId,
expectedSpanId,
ActivityTraceFlags.Recorded,
isRemote: true),
default));
var activity = new Activity(HttpInListener.ActivityOperationName);
if (carrierFormat == "TraceContext" || carrierFormat == "CustomContextMatchParent")
{
activity.SetParentId(expectedTraceId, expectedSpanId, ActivityTraceFlags.Recorded);
}
var activityProcessor = new Mock<BaseProcessor<Activity>>();
Sdk.SetDefaultTextMapPropagator(propagator.Object);
using (openTelemetry = Sdk.CreateTracerProviderBuilder()
.AddAspNetInstrumentation(
(options) =>
{
options.Filter = httpContext =>
{
if (string.IsNullOrEmpty(filter))
{
return true;
}
if (filter == "{ThrowException}")
{
throw new InvalidOperationException();
}
return httpContext.Request.Path != filter;
};
if (setStatusToErrorInEnrich)
{
options.Enrich = GetEnrichmentAction(Status.Error);
}
else
{
options.Enrich = GetEnrichmentAction(default);
}
})
.AddProcessor(activityProcessor.Object).Build())
{
activity.Start();
using (var inMemoryEventListener = new InMemoryEventListener(AspNetInstrumentationEventSource.Log))
{
this.fakeAspNetDiagnosticSource.Write("Start", null);
if (filter == "{ThrowException}")
{
Assert.Single(inMemoryEventListener.Events.Where((e) => e.EventId == 3));
}
}
if (restoreCurrentActivity)
{
Activity.Current = activity;
}
this.fakeAspNetDiagnosticSource.Write("Stop", null);
// The above line fires DS event which is listened by Instrumentation.
// Validate that Current activity is still the one created by Asp.Net
Assert.Equal(HttpInListener.ActivityOperationName, Activity.Current.OperationName);
activity.Stop();
}
if (HttpContext.Current.Request.Path == filter || filter == "{ThrowException}")
{
// only SetParentProvider/Shutdown/Dispose/OnStart are called because request was filtered.
Assert.Equal(4, activityProcessor.Invocations.Count);
return;
}
// Validate that Activity.Current is always the one created by Asp.Net
var currentActivity = Activity.Current;
Activity span;
if (carrierFormat == "CustomContextNonmatchParent")
{
Assert.Equal(6, activityProcessor.Invocations.Count); // SetParentProvider/OnStart(framework activity)/OnStart(sibling activity)/OnEnd(sibling activity)/OnShutdown/Dispose called.
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.Contains(startedActivities, item =>
{
var startedActivity = item.Arguments[0] as Activity;
return startedActivity.OperationName == HttpInListener.ActivityOperationName;
});
Assert.Contains(startedActivities, item =>
{
var startedActivity = item.Arguments[0] as Activity;
return startedActivity.OperationName == HttpInListener.ActivityNameByHttpInListener;
});
// Only the sibling activity is sent to Processor.OnEnd
Assert.Contains(stoppedActivities, item =>
{
var stoppedActivity = item.Arguments[0] as Activity;
return stoppedActivity.OperationName == HttpInListener.ActivityNameByHttpInListener;
});
}
else
{
Assert.Equal(5, activityProcessor.Invocations.Count); // SetParentProvider/OnStart/OnEnd/OnShutdown/Dispose called.
var startedActivities = activityProcessor.Invocations.Where(invo => invo.Method.Name == "OnStart");
var stoppedActivities = activityProcessor.Invocations.Where(invo => invo.Method.Name == "OnEnd");
// There is no sibling activity created
Assert.Single(startedActivities);
Assert.Single(stoppedActivities);
Assert.Contains(startedActivities, item =>
{
var startedActivity = item.Arguments[0] as Activity;
return startedActivity.OperationName == HttpInListener.ActivityOperationName;
});
// Only the sibling activity is sent to Processor.OnEnd
Assert.Contains(stoppedActivities, item =>
{
var stoppedActivity = item.Arguments[0] as Activity;
return stoppedActivity.OperationName == HttpInListener.ActivityOperationName;
});
}
span = (Activity)activityProcessor.Invocations[2].Arguments[0];
Assert.Equal(
carrierFormat == "TraceContext" || carrierFormat == "CustomContextMatchParent"
? HttpInListener.ActivityOperationName
: HttpInListener.ActivityNameByHttpInListener,
span.OperationName);
Assert.NotEqual(TimeSpan.Zero, span.Duration);
Assert.Equal(expectedTraceId, span.TraceId);
Assert.Equal(expectedSpanId, span.ParentSpanId);
Assert.Equal(routeTemplate ?? HttpContext.Current.Request.Path, span.DisplayName);
Assert.Equal(ActivityKind.Server, span.Kind);
Assert.True(span.Duration != TimeSpan.Zero);
Assert.Equal(200, span.GetTagValue(SemanticConventions.AttributeHttpStatusCode));
if (setStatusToErrorInEnrich)
{
// This validates that users can override the
// status in Enrich.
Assert.Equal(Status.Error, span.GetStatus());
// Instrumentation is not expected to set status description
// as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode
Assert.True(string.IsNullOrEmpty(span.GetStatus().Description));
}
else
{
Assert.Equal(Status.Unset, span.GetStatus());
// Instrumentation is not expected to set status description
// as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode
Assert.True(string.IsNullOrEmpty(span.GetStatus().Description));
}
var expectedUri = new Uri(url);
var actualUrl = span.GetTagValue(SemanticConventions.AttributeHttpUrl);
Assert.Equal(expectedUri.ToString(), actualUrl);
// Url strips 80 or 443 if the scheme matches.
if ((expectedUri.Port == 80 && expectedUri.Scheme == "http") || (expectedUri.Port == 443 && expectedUri.Scheme == "https"))
{
Assert.DoesNotContain($":{expectedUri.Port}", actualUrl as string);
}
else
{
Assert.Contains($":{expectedUri.Port}", actualUrl as string);
}
// Host includes port if it isn't 80 or 443.
if (expectedUri.Port == 80 || expectedUri.Port == 443)
{
Assert.Equal(
expectedUri.Host,
span.GetTagValue(SemanticConventions.AttributeHttpHost) as string);
}
else
{
Assert.Equal(
$"{expectedUri.Host}:{expectedUri.Port}",
span.GetTagValue(SemanticConventions.AttributeHttpHost) as string);
}
Assert.Equal(HttpContext.Current.Request.HttpMethod, span.GetTagValue(SemanticConventions.AttributeHttpMethod) as string);
Assert.Equal(HttpContext.Current.Request.Path, span.GetTagValue(SpanAttributeConstants.HttpPathKey) as string);
Assert.Equal(HttpContext.Current.Request.UserAgent, span.GetTagValue(SemanticConventions.AttributeHttpUserAgent) as string);
}
private static Action<Activity, string, object> GetEnrichmentAction(Status statusToBeSet)
{
Action<Activity, string, object> enrichAction;
enrichAction = (activity, method, obj) =>
{
switch (method)
{
case "OnStartActivity":
Assert.True(obj is HttpRequest);
break;
case "OnStopActivity":
Assert.True(obj is HttpResponse);
if (statusToBeSet != default)
{
activity.SetStatus(statusToBeSet);
}
break;
default:
break;
}
};
return enrichAction;
}
private class FakeAspNetDiagnosticSource : IDisposable
{
private readonly DiagnosticListener listener;
public FakeAspNetDiagnosticSource()
{
this.listener = new DiagnosticListener(AspNetInstrumentation.AspNetDiagnosticListenerName);
}
public void Write(string name, object value)
{
this.listener.Write(name, value);
}
public void Dispose()
{
this.listener.Dispose();
}
}
}
}