src/integrations/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs (586 lines of code) (raw):

// Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Data.SqlClient; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Security.Claims; using System.Threading; using System.Web; using Elastic.Apm.Api; using Elastic.Apm.AspNetFullFramework.Extensions; using Elastic.Apm.Config.Net4FullFramework; using Elastic.Apm.Extensions; using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using Elastic.Apm.Model; using Elastic.Apm.Reflection; using Environment = System.Environment; using TraceContext = Elastic.Apm.DistributedTracing.TraceContext; namespace Elastic.Apm.AspNetFullFramework { /// <summary> /// Captures each request in an APM transaction /// </summary> public class ElasticApmModule : IHttpModule { private static volatile bool ApplicationStarted = false; private static readonly object ApplicationStartedLock = new(); private static bool IsCaptureHeadersEnabled; private static bool UsingIntegratedPipeline = true; private static readonly LazyContextualInit InitOnceHelper = new(); private static readonly MethodInfo OnExecuteRequestStepMethodInfo = typeof(HttpApplication).GetMethod("OnExecuteRequestStep"); private readonly string _dbgInstanceName; private IApmLogger _logger; private readonly Lazy<Type> _httpRouteDataInterfaceType = new(() => Type.GetType("System.Web.Http.Routing.IHttpRouteData,System.Web.Http")); private Func<object, string> _routeDataTemplateGetter; private Func<object, decimal> _routePrecedenceGetter; private static int InstanceCount; public ElasticApmModule() { var instanceCounter = Interlocked.Increment(ref InstanceCount); _dbgInstanceName = $"{nameof(ElasticApmModule)}.#{instanceCounter}"; } /// <inheritdoc /> public void Init(HttpApplication application) { // This is not guarded inside a try/catch as it should not be possible for this to throw an exception. _logger ??= (AgentDependencies.Logger ?? FullFrameworkDefaultImplementations.CreateDefaultLogger(null)).Scoped(_dbgInstanceName); _logger.Trace()?.Log($"{nameof(ElasticApmModule)}.{nameof(Init)} was invoked and called {nameof(AttemptAgentInitialization)}."); try { // If we've already attempted initialisation and determined we are not in integrated mode, we can return quickly here. // As `UsingIntegratedPipeline` is initialised as `true`, we pass through here at least once. if (!UsingIntegratedPipeline) return; if (!ApplicationStarted) { AttemptAgentInitialization(_logger); } else { // If the app was already started by the time Init was called, we won't yet have a scoped logger to use, so create one. _logger ??= CreateScopedLogger(); _logger.Trace() ?.Log($"{nameof(ElasticApmModule)}.{nameof(Init)} was invoked by an instance when the Agent has already been initialized. " + "No further initialization required."); } if (!Agent.Config.Enabled) { _logger.Trace()?.Log("Agent not enabled. Skipping registration of HttpApplication event handlers."); return; } _routeDataTemplateGetter = CreateWebApiAttributeRouteTemplateGetter(); _routePrecedenceGetter = CreateRoutePrecedenceGetter(); _logger.Trace()?.Log("Registering to HttpApplication event handlers."); application.BeginRequest += OnBeginRequest; application.EndRequest += OnEndRequest; application.Error += OnError; if (OnExecuteRequestStepMethodInfo != null) { _logger.Trace() ?.Log("Registering to HttpApplication.OnExecuteRequestStep."); // OnExecuteRequestStep is available starting with 4.7.1 try { #pragma warning disable IDE0300 // Simplify collection initialization OnExecuteRequestStepMethodInfo.Invoke(application, new object[] { (Action<HttpContextBase, Action>)OnExecuteRequestStep }); #pragma warning restore IDE0300 // Simplify collection initialization } catch (Exception e) { _logger.Error() ?.LogException(e, "Failed to invoke OnExecuteRequestStep. .NET runtime: {DotNetRuntimeDescription}; IIS: {IisVersion}", PlatformDetection.DotNetRuntimeDescription, HttpRuntime.IISVersion); } } } catch (Exception ex) { _logger.Critical()?.LogException(ex, $"Exception thrown by {nameof(ElasticApmModule)}.{nameof(Init)}."); } } private void AttemptAgentInitialization(IApmLogger logger) { // We use a lock here to ensure that if multiple `HttpApplication` instances are created, each calling the `Init` method // on registered module, that we initialise the agent only once. The first instance wins and any concurrent calls to `Init` // should block until this is completed such that they only continue once our initialisation is completed. lock (ApplicationStartedLock) { if (ApplicationStarted) { _logger ??= CreateScopedLogger(); _logger.Trace()?.Log("Lock aquired, but Agent singleton has already been initialized. Skipping initialization."); return; } var agentComponents = CreateAgentComponents(_dbgInstanceName, logger); _logger = agentComponents.Logger.Scoped(_dbgInstanceName); _logger.Trace()?.Log("Initializing singleton Agent."); // We store this in a static field as it should be consistent for all invocations. We can then log our error once but // also short-circuit subsequent `Init` calls when we've already determined the app is hosted on an incompatible pipeline. UsingIntegratedPipeline = HttpRuntime.UsingIntegratedPipeline; if (!UsingIntegratedPipeline) { _logger.Error() ?.Log("Skipping Agent initialization. Elastic APM Module requires the IIS Application Pool to run under an Integrated Pipeline." + " .NET runtime: {DotNetRuntimeDescription}; IIS: {IisVersion}", PlatformDetection.DotNetRuntimeDescription, HttpRuntime.IISVersion); return; } Agent.Setup(agentComponents); _logger.Debug() ?.Log("Initialized Agent singleton. .NET runtime: {DotNetRuntimeDescription}; IIS: {IisVersion}", PlatformDetection.DotNetRuntimeDescription, HttpRuntime.IISVersion); if (!Agent.Instance.Configuration.Enabled) return; IsCaptureHeadersEnabled = Agent.Instance.Configuration.CaptureHeaders; Agent.Instance.SubscribeIncludingAllDefaults(); ApplicationStarted = true; } } private ScopedLogger CreateScopedLogger() => Agent.Instance.Logger.Scoped(_dbgInstanceName); /// <summary> /// Creates a new instance of <see cref="AgentComponents"/> configured /// to use with ASP.NET Full Framework. /// </summary> /// <returns>a new instance of <see cref="AgentComponents"/></returns> public static AgentComponents CreateAgentComponents() => CreateAgentComponents($"{nameof(ElasticApmModule)}.#0"); internal static AgentComponents CreateAgentComponents(string debugName, IApmLogger apmLogger = null) { var logger = apmLogger ?? AgentDependencies.Logger ?? FullFrameworkDefaultImplementations.CreateDefaultLogger(null); var config = FullFrameworkDefaultImplementations.CreateConfigurationReaderFromConfiguredType(logger) ?? new ElasticApmModuleConfiguration(logger); var agentComponents = new AgentComponentsUsingHttpContext(logger, config); agentComponents.Service.Language = new Language { Name = "C#" }; //TODO var scopedLogger = logger.Scoped(debugName); var aspNetVersion = AspNetVersion.GetEngineVersion(scopedLogger); if (aspNetVersion != null) agentComponents.Service.Framework = new Framework { Name = "ASP.NET", Version = aspNetVersion }; return agentComponents; } private void RestoreContextIfNeeded(HttpContextBase context) { string EventName() => Enum.GetName(typeof(RequestNotification), context.CurrentNotification); var urlPath = TryGetUrlPath(context); var ignoreUrls = Agent.Instance?.Configuration.TransactionIgnoreUrls; if (urlPath != null && ignoreUrls != null && WildcardMatcher.IsAnyMatch(ignoreUrls, urlPath)) return; if (Agent.Instance == null) { _logger.Trace()? .Log("Agent.Instance is null during {RequestNotification}. url: {{UrlPath}}", $"{nameof(OnExecuteRequestStep)}:{EventName()}", urlPath); return; } if (Agent.Instance.Tracer == null) { _logger.Trace()? .Log("Agent.Instance.Tracer is null during {RequestNotification}. url: {{UrlPath}}", $"{nameof(OnExecuteRequestStep)}:{EventName()}", urlPath); return; } var transaction = Agent.Instance?.Tracer?.CurrentTransaction; if (transaction != null) return; if (Agent.Config.LogLevel <= LogLevel.Trace) return; var transactionInCurrent = HttpContext.Current?.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentTransactionKey] is not null; var transactionInApplicationInstance = context.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentTransactionKey] is not null; var spanInCurrent = HttpContext.Current?.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentSpanKey] is not null; var spanInApplicationInstance = context.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentSpanKey] is not null; _logger.Trace()? .Log($"{nameof(ITracer.CurrentTransaction)} is null during {{RequestNotification}}. url: {{UrlPath}}" + "(HttpContext.Current Span: {HttpContextCurrentHasSpan}, Transaction: {HttpContextCurrenHasTransaction})" + "(ApplicationContext Span: {ApplicationContextHasSpan}, Transaction: {ApplicationContextHasTransaction})", $"{nameof(OnExecuteRequestStep)}:{EventName()}", urlPath, spanInCurrent, transactionInCurrent, spanInApplicationInstance, transactionInApplicationInstance ); if (HttpContext.Current == null) { _logger.Trace()? .Log("HttpContext.Current is null during {RequestNotification}. Unable to attempt to restore transaction. url: {UrlPath}", $"{nameof(OnExecuteRequestStep)}:{EventName()}", urlPath); return; } if (!transactionInCurrent && transactionInApplicationInstance) { HttpContext.Current.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentTransactionKey] = context.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentTransactionKey]; _logger.Trace()?.Log("Restored transaction to HttpContext.Current.Items {RequestNotification}. url: {UrlPath}", $"{nameof(OnExecuteRequestStep)}:{EventName()}", urlPath); } if (!spanInCurrent && spanInApplicationInstance) { HttpContext.Current.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentSpanKey] = context.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentSpanKey]; _logger.Trace()?.Log("Restored span to HttpContext.Current.Items {RequestNotification}:{EventName()}. url: {UrlPath}", $"{nameof(OnExecuteRequestStep)}:{EventName()}", urlPath); } } private string TryGetUrlPath(HttpContextBase context) { try { return context.Request.Unvalidated.Path; } catch { //ignore return string.Empty; } } private void OnExecuteRequestStep(HttpContextBase context, Action step) { RestoreContextIfNeeded(context); step(); } private void OnBeginRequest(object sender, EventArgs e) { _logger.Debug()?.Log("Incoming request processing started - starting trace..."); try { var usingLegacySynchronizationContext = SynchronizationContext.Current?.GetType().Name == "LegacyAspNetSynchronizationContext"; if (usingLegacySynchronizationContext) _logger.Warning()?.Log("ASP.NET is using LegacyAspNetSynchronizationContext and might not behave well for asynchronous code"); } catch { // ignored } try { ProcessBeginRequest(sender); } catch (Exception ex) { _logger.Error()?.LogException(ex, "Processing BeginRequest event failed"); } } private void OnError(object sender, EventArgs e) { try { ProcessError(sender); } catch (Exception ex) { _logger.Error()?.LogException(ex, "Processing Error event failed"); } } private void OnEndRequest(object sender, EventArgs e) { _logger.Debug()?.Log("Incoming request processing finished - ending trace..."); try { ProcessEndRequest(sender); } catch (Exception ex) { _logger.Error()?.LogException(ex, "Processing EndRequest event failed"); } } private void ProcessBeginRequest(object sender) { var application = (HttpApplication)sender; var request = application.Context.Request; if (WildcardMatcher.IsAnyMatch(Agent.Instance.Configuration.TransactionIgnoreUrls, request.Unvalidated.Path)) { _logger.Debug()?.Log("Request ignored based on TransactionIgnoreUrls, url: {urlPath}", request.Unvalidated.Path); return; } // Set the initial transaction name based on the request path, if enabled in configuration (default is true). var transactionName = Agent.Instance.Configuration.UsePathAsTransactionName ? $"{request.HttpMethod} {request.Unvalidated.Path}" : $"{request.HttpMethod} unknown route"; var distributedTracingData = ExtractIncomingDistributedTracingData(request); ITransaction transaction; if (distributedTracingData != null) { _logger.Debug() ?.Log( "Incoming request with {TraceParentHeaderName} header. DistributedTracingData: {DistributedTracingData} - continuing trace", TraceContext.TraceParentHeaderName, distributedTracingData); // we set ignoreActivity to true to avoid the HttpContext W3C DiagnosticSource issue (see https://github.com/elastic/apm-agent-dotnet/issues/867#issuecomment-650170150) transaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, distributedTracingData, true); } else { _logger.Debug() ?.Log("Incoming request doesn't have valid incoming distributed tracing data - starting trace with new trace ID"); // we set ignoreActivity to true to avoid the HttpContext W3C DiagnosticSource issue(see https://github.com/elastic/apm-agent-dotnet/issues/867#issuecomment-650170150) transaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, ignoreActivity: true); } if (transaction.IsSampled) FillSampledTransactionContextRequest(request, transaction, _logger); } /// <summary> /// Extracts the traceparent and the tracestate headers from the request /// </summary> /// <param name="request">The request</param> /// <returns>Null if traceparent is not set, otherwise the filled DistributedTracingData instance</returns> private DistributedTracingData ExtractIncomingDistributedTracingData(HttpRequest request) { var traceParentHeaderValue = request.Unvalidated.Headers.Get(TraceContext.TraceParentHeaderName); // ReSharper disable once InvertIf if (traceParentHeaderValue == null) { traceParentHeaderValue = request.Unvalidated.Headers.Get(TraceContext.TraceParentHeaderNamePrefixed); if (traceParentHeaderValue == null) { _logger.Debug() ?.Log("Incoming request doesn't have {TraceParentHeaderName} header - " + "it means request doesn't have incoming distributed tracing data", TraceContext.TraceParentHeaderNamePrefixed); return null; } } var traceStateHeaderValue = request.Unvalidated.Headers.Get(TraceContext.TraceStateHeaderName); return TraceContext.TryExtractTracingData(traceParentHeaderValue, traceStateHeaderValue); } private static void FillSampledTransactionContextRequest(HttpRequest request, ITransaction transaction, IApmLogger logger) { var httpRequestUrl = request.Unvalidated.Url; var queryString = httpRequestUrl.Query; var fullUrl = httpRequestUrl.AbsoluteUri; if (queryString.IsEmpty()) { // Uri.Query returns empty string both when query string is empty ("http://host/path?") and // when there's no query string at all ("http://host/path") so we need a way to distinguish between these cases // HttpRequest.RawUrl contains only raw URL path and query (not a full raw URL with protocol, host, etc.) if (request.Unvalidated.RawUrl.IndexOf('?') == -1) queryString = null; else if (!fullUrl.IsEmpty() && fullUrl[fullUrl.Length - 1] != '?') fullUrl += "?"; } else if (queryString[0] == '?') queryString = queryString.Substring(1, queryString.Length - 1); var url = new Url { Full = fullUrl, HostName = httpRequestUrl.Host, Protocol = "HTTP", Raw = fullUrl, PathName = httpRequestUrl.AbsolutePath, Search = queryString }; transaction.Context.Request = new Request(request.HttpMethod, url) { Socket = new Socket { RemoteAddress = request.UserHostAddress }, HttpVersion = GetHttpVersion(request.ServerVariables["SERVER_PROTOCOL"]), Headers = IsCaptureHeadersEnabled ? ConvertHeaders(request.Unvalidated.Headers) : null }; } private static string GetHttpVersion(string protocol) { switch (protocol) { case "HTTP/1.0": return "1.0"; case "HTTP/1.1": return "1.1"; case "HTTP/2.0": return "2.0"; default: return protocol?.Replace("HTTP/", string.Empty); } } private static Dictionary<string, string> ConvertHeaders(NameValueCollection headers) { var convertedHeaders = new Dictionary<string, string>(headers.Count); foreach (var key in headers.AllKeys) { var value = headers.Get(key); if (value != null) convertedHeaders.Add(key, value); } return convertedHeaders; } private void ProcessError(object sender) { var transaction = Agent.Instance.Tracer.CurrentTransaction; if (transaction is null) return; var application = (HttpApplication)sender; var exception = application.Server.GetLastError(); if (exception != null) { if (exception is HttpUnhandledException unhandledException && unhandledException.InnerException != null) exception = unhandledException.InnerException; transaction.CaptureException(exception); } } private void ProcessEndRequest(object sender) { var application = (HttpApplication)sender; var context = application.Context; var request = context.Request; var transaction = Agent.Instance.Tracer.CurrentTransaction; if (transaction is null) { // We expect transaction to be null if `TransactionIgnoreUrls` matches if (WildcardMatcher.IsAnyMatch(Agent.Instance.Configuration.TransactionIgnoreUrls, request.Unvalidated.Path)) return; var hasHttpContext = HttpContext.Current?.Items[HttpContextCurrentExecutionSegmentsContainer.CurrentTransactionKey] is not null; _logger.Warning() ?.Log( $"{nameof(ITracer.CurrentTransaction)} is null in {nameof(ProcessEndRequest)}. HttpContext for transaction: {hasHttpContext}" ); return; } var response = context.Response; // update the transaction name based on route values, if applicable if (transaction is Transaction t && !t.HasCustomName) { var values = request.RequestContext?.RouteData?.Values; if (values?.Count > 0) { // Determine if the route data *actually* routed to a controller action or not i.e. // we need to differentiate between // 1. route data that didn't route to a controller action and returned a 404 // 2. route data that did route to a controller action, and the action result returned a 404 // // In normal MVC setup, the former will set a HttpException with a 404 status code with System.Web.Mvc as the source. // We need to check the source of the exception because we want to differentiate between a 404 HttpException from the // framework and a 404 HttpException from the application. if (context.Error is not HttpException httpException || httpException.Source != "System.Web.Mvc" || httpException.GetHttpCode() != 404) { // handle MVC areas. The area name will be included in the DataTokens. object area = null; request.RequestContext?.RouteData?.DataTokens?.TryGetValue("area", out area); IDictionary<string, object> routeData; if (area != null) { routeData = new Dictionary<string, object>(values.Count + 1); foreach (var value in values) routeData.Add(value.Key, value.Value); routeData.Add("area", area); } else routeData = values; string name = null; // if we're dealing with Web API attribute routing, get transaction name from the route template if (routeData.TryGetValue("MS_SubRoutes", out var template) && _httpRouteDataInterfaceType.Value != null) { if (template is IEnumerable enumerable) { var minPrecedence = decimal.MaxValue; var enumerator = enumerable.GetEnumerator(); while (enumerator.MoveNext()) { var subRoute = enumerator.Current; if (subRoute != null && _httpRouteDataInterfaceType.Value.IsInstanceOfType(subRoute)) { var precedence = _routePrecedenceGetter(subRoute); if (precedence < minPrecedence) { _logger?.Trace() ?.Log( $"Calculating transaction name from web api attribute routing (route precedence: {precedence})"); minPrecedence = precedence; name = _routeDataTemplateGetter(subRoute); } } } } } else { _logger?.Trace()?.Log("Calculating transaction name based on route data"); name = Transaction.GetNameFromRouteContext(routeData); } if (!string.IsNullOrWhiteSpace(name)) transaction.Name = $"{context.Request.HttpMethod} {name}"; } else { // dealing with a 404 HttpException that came from System.Web.Mvc _logger?.Trace() ? .Log( "Route data found but a HttpException with 404 status code was thrown from System.Web.Mvc - setting transaction name to 'unknown route"); transaction.Name = $"{context.Request.HttpMethod} unknown route"; } } } transaction.Result = Transaction.StatusCodeToResult("HTTP", response.StatusCode); var realTransaction = transaction as Transaction; realTransaction?.SetOutcome(response.StatusCode >= 500 ? Outcome.Failure : Outcome.Success); // Try and update transaction name with SOAP action if applicable. if (realTransaction == null || !realTransaction.HasCustomName) { if (SoapRequest.TryExtractSoapAction(_logger, context.Request, out var soapAction)) transaction.Name += $" {soapAction}"; } if (transaction.IsSampled) { FillSampledTransactionContextResponse(response, transaction); FillSampledTransactionContextUser(context, transaction); transaction.CollectRequestBody(false, new AspNetHttpRequest(context.Request), _logger); } transaction.End(); transaction = null; } private static void FillSampledTransactionContextResponse(HttpResponse response, ITransaction transaction) => transaction.Context.Response = new Response { Finished = true, StatusCode = response.StatusCode, Headers = IsCaptureHeadersEnabled ? ConvertHeaders(response.Headers) : null }; private void FillSampledTransactionContextUser(HttpContext context, ITransaction transaction) { if (transaction.Context.User != null) return; var userIdentity = context.User?.Identity; if (userIdentity == null || !userIdentity.IsAuthenticated) return; var user = new User { UserName = userIdentity.Name }; FillUserIdentity(context, user); transaction.Context.User = user; _logger.Debug()?.Log("Captured user - {CapturedUser}", transaction.Context.User); } private void FillUserIdentity(HttpContext context, User user) { try { var sqlRoleProvider = System.Web.Security.Roles.Enabled && System.Web.Security.Roles.Providers.Cast<object>().Any(provider => provider.GetType().Name == "SqlRoleProvider"); if (sqlRoleProvider || context.User is not ClaimsPrincipal claimsPrincipal) return; try { static string GetClaimWithFallbackValue(ClaimsPrincipal principal, string claimType, string fallbackClaimType) { var claim = principal.Claims.FirstOrDefault(n => n.Type == claimType || n.Type == fallbackClaimType); return claim != null ? claim.Value : string.Empty; } user.Email = GetClaimWithFallbackValue(claimsPrincipal, ClaimTypes.Email, OpenIdClaimTypes.Email); user.Id = GetClaimWithFallbackValue(claimsPrincipal, ClaimTypes.NameIdentifier, OpenIdClaimTypes.UserId); } catch (SqlException ex) { _logger.Error()?.Log("Unable to access user claims due to SqlException with message: {message}", ex.Message); } } catch (Exception ex) { _logger.Trace()?.Log("Error accessing System.Web.Security.Roles: {message}", ex.Message); } } /// <summary> /// Compiles a delegate from a lambda expression to get a route's DataTokens property, /// which holds the precedence value. /// </summary> private Func<object, decimal> CreateRoutePrecedenceGetter() { if (_httpRouteDataInterfaceType.Value != null) { var routePropertyInfo = _httpRouteDataInterfaceType.Value.GetProperty("Route"); if (routePropertyInfo != null) { var routeType = routePropertyInfo.PropertyType; var dataTokensPropertyInfo = routeType.GetProperty("DataTokens"); if (dataTokensPropertyInfo != null) { var routePropertyGetter = ExpressionBuilder.BuildPropertyGetter(_httpRouteDataInterfaceType.Value, routePropertyInfo); var dataTokensPropertyGetter = ExpressionBuilder.BuildPropertyGetter(routeType, dataTokensPropertyInfo); return subRoute => { var precedence = decimal.MaxValue; var route = routePropertyGetter(subRoute); if (route != null) { var dataTokens = dataTokensPropertyGetter(route) as IDictionary<string, object>; object v = null; if (dataTokens?.TryGetValue("precedence", out v) ?? true) precedence = (decimal)v; } return precedence; }; } } } return null; } /// <summary> /// Compiles a delegate from a lambda expression to get the route template from HttpRouteData when /// System.Web.Http is referenced. /// </summary> private Func<object, string> CreateWebApiAttributeRouteTemplateGetter() { if (_httpRouteDataInterfaceType.Value != null) { var routePropertyInfo = _httpRouteDataInterfaceType.Value.GetProperty("Route"); if (routePropertyInfo != null) { var routeType = routePropertyInfo.PropertyType; var routeTemplatePropertyInfo = routeType.GetProperty("RouteTemplate"); if (routeTemplatePropertyInfo != null) { var routePropertyGetter = ExpressionBuilder.BuildPropertyGetter(_httpRouteDataInterfaceType.Value, routePropertyInfo); var routeTemplatePropertyGetter = ExpressionBuilder.BuildPropertyGetter(routeType, routeTemplatePropertyInfo); return routeData => { var route = routePropertyGetter(routeData); return route is null ? null : routeTemplatePropertyGetter(route) as string; }; } } } return null; } public void Dispose() { } } }