Search code examples
asp.netowin

W3C TraceId Propagation in ASP.NET MVC 4/5/6


I have some old applications on ASP.NET MVC 5 and 6, and I want to accomplish something like the automatic W3C trace ID propagation that ASP.NET Core does. (E.g., reading the incoming traceparent request header setting the Activity.Current property accordingly. Storing the value in the Request context is not ideal, as it won't automatically be available to the other tasks, but it would be a great start.)

I would like to have some middleware that runs before every controller and sets the Activity. To create this middleware, do I need to use OWIN? Or is there something built into ASP.NET MVC that I can use to run code to read the request before it is handed over to the controller methods?

I tried setting up an OWIN middleware to try setting the Activity.Current property and the OwinContext. I don't know if I'm doing it right, but the Activity.Current is always null, as is the Request.GetOwinContext() call in the controller is always null as well, even though I can see the values before it passes from the OWIN middleware to the controller, and the thread ID is the same in the lambda function in Startup.cs and the ValuesController.Get method.

It seems like the OWIN middleware is exiting before the controller is even being called, so maybe that's why? I'm not sure if I wired up OWIN with ASP.NET correctly.

// Startup.cs
using Microsoft.Owin;
using Owin;
using System;
using System.Diagnostics;
using System.Reflection;
using System.Threading.Tasks;
using System.Web.Http;

[assembly: OwinStartup(typeof(ActivityDemo.Startup))]

namespace ActivityDemo
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            Activity.DefaultIdFormat = ActivityIdFormat.W3C;
            app.Use(async (ctx, next) =>
            {
                ActivityContext activityCtx = default;
                string traceparent = ctx.Request.Headers.Get("traceparent");
                if (traceparent != null)
                {
                    try
                    {
                        string tracestate = ctx.Request.Headers.Get("tracestate");
                        ActivityContext.TryParse(traceparent, tracestate, out activityCtx);
                    }
                    catch { }
                }
                // We depend on the activity being set (for logging with Serilog),
                // so create one manually even if no one is listening to the
                // ActivitySource.
                Activity activity = new Activity("ReceiveHttpRequest").SetParentId(traceparent).Start();

                // ctx.Request.Set("traceparent", traceparent);
                ctx.Environment["traceparent"] = traceparent;
                var current = Activity.Current; // inserted to inspect with debugger
                await next();
            });
            var config = new HttpConfiguration();
            config.MapHttpAttributeRoutes();
            app.UseWebApi(config);
            // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=316888
            // app.Run();
        }
    }
}
// ValuesController.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

namespace ActivityDemo.Controllers
{
    public class ValuesController : ApiController
    {
        // GET api/values
        public IEnumerable<string> Get()
        {
            var activity = Activity.Current;
            var owinContext = Request.GetOwinContext(); // inserted to inspect with debugger, always `null`
            var owinEnv = Request.GetOwinEnvironment(); // inserted to inspect with debugger, always `null`
            return new string[] { "value1", "value2", Activity.Current?.TraceId.ToString() };
        }
    }
}

This is just the first thing that I tried; I'm open to non-OWIN solutions as well.


Solution

  • I think I got it!

    1. Add the System.Diagnostics.DiagnosticSource and System.Reactive.Core packages to your ASP.NET project.
    2. Create a new file, App_Start\DiagnosticsConfig.cs with the following content:
    // DiagnosticsConfig.cs
    
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Web;
    
    namespace DiagnosticsTest
    {
        public class DiagnosticsConfig
        {
            // Set up an ActivitySource
            private static readonly AssemblyName AssemblyName =
                typeof(DiagnosticsConfig).Assembly.GetName();
            internal static readonly ActivitySource ActivitySource =
                new ActivitySource(AssemblyName.Name, AssemblyName.Version.ToString());
    
            public static void StartDiagnosticsListeners()
            {
                // Set the format to W3C
                Activity.DefaultIdFormat = ActivityIdFormat.W3C;
                Activity.ForceDefaultIdFormat = true;
                
                // Start the ActivityListener
                ActivitySource.AddActivityListener(new ActivityListener()
                {
                    Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
                    ShouldListenTo = _ => true,
                }) ;
    
    
                // Using the Subscribe(IObservable<T>, Action<T>) extension method from
                // the System.Reactive.Core package.
                var subscription = DiagnosticListener.AllListeners.Subscribe(delegate (DiagnosticListener listener)
                {
                    if (listener.Name == "System.Net.Http.Desktop")
                    {
                        listener.Subscribe(delegate (KeyValuePair<string, object> diagnostic)
                        {
                            if (
                                diagnostic.Key == "System.Net.Http.Desktop.HttpRequestOut.Start"
                                && Activity.Current != null
                            )
                            {
                                // Set the parent to workaround HttpDiagnosticListener adding
                                // an extra intermediate span.
                                HttpWebRequest request = diagnostic.Value.GetType()
                                    .GetRuntimeProperty("Request")
                                    .GetValue(diagnostic.Value) as HttpWebRequest;
                                request.Headers.Add("traceparent", Activity.Current.ParentId);
                                var tracestate = Activity.Current.TraceStateString;
                                if (!string.IsNullOrWhiteSpace(tracestate))
                                {
                                    request.Headers.Add("tracestate", tracestate);
                                }
                            }
                        });
                    }
                    
                    // You can listen to other DiagnosticSources by adding more
                    // handlers here.
                });
            }
        }
    }
    
    1. Parse the traceparent header Application_BeginRequest() and Application_EndRequest() methods of Global.Asax.cs. Also, register the listener.
    // Global.asax.cs
    
    using System.Diagnostics;
    using System.Web;
    using System.Web.Http;
    using System.Web.Mvc;
    using System.Web.Optimization;
    using System.Web.Routing;
    
    namespace DiagnosticsTest
    {
        public class WebApiApplication : HttpApplication
        {
            protected void Application_Start()
            {
                // <Other startup boilerplate>
                // ...
                
                // Register your DiagnosticSource
                DiagnosticsConfig.StartDiagnosticsListeners();
            }
    
            protected void Application_BeginRequest()
            {
                // Parse traceparent on incoming requests.
                var ctx = HttpContext.Current;
                const string operationName = "MyCompany.AspNet.HandleRequest";
    
                var traceParent = ctx.Request.Headers["traceparent"];
                var traceState = ctx.Request.Headers["tracestate"];
                Activity activity = (traceParent != null && ActivityContext.TryParse(traceParent, traceState, out ActivityContext activityCtx))
                    ? DiagnosticsConfig.ActivitySource.StartActivity(operationName, ActivityKind.Server, activityCtx)
                    : DiagnosticsConfig.ActivitySource.StartActivity(operationName, ActivityKind.Server);
                ctx.Items["activity"] = activity;
            }
    
            protected void Application_EndRequest()
            {
                // Clean up the Activity at the end of the request.
                var ctx = HttpContext.Current;
                var activity = (Activity)ctx.Items["activity"];
                activity.Stop();
            }
        }
    }
    

    After that, you should have Activity.Current set to the incoming traceparent header for your Controller code/logs, and outgoing requests from System.Net.Http will automatically begin a new span.