Tips

Adapting Custom HTTP Headers to OpenTelemetry in ASP.NET Core

Explains how to convert legacy X-Request-Id and X-Trace-Id request headers to W3C Trace Context in ASP.NET Core to integrate with OpenTelemetry tracing.

Published

The W3C TraceContext V1 standard was not published until November 2021. Before that, many systems used custom HTTP Header fields to trace requests and call chains. For example, AWS S3 uses X-Amzn-Trace-Id for tracing. This article tells you how to bridge X-Request-Id and X-Trace-Id from legacy systems to the latest OpenTelemetry framework in ASP.NET.

Obviously, you can always read the X-Request-Id and X-Trace-Id fields from the HTTP Request Header somewhere, and then generate a traceparent field yourself. The main question is where to do this. Clearly, this should happen before the ASP.NET and OpenTelemetry frameworks start processing the traceparent field. So for us, the earlier this conversion happens, the better. After all, we are adding new information and will not cause information loss.

If your application has another SLB (Software Load Balancer) layer in front of it, such as Nginx, you can absolutely implement this conversion logic at that layer.

If your application no longer has an SLB in front of it and you need to handle this issue inside the ASP.NET framework, how should you do it?

After some research, I found that HttpContextFactory is a relatively suitable place. HttpContextFactory creates the HttpContext, and then ASP.NET starts pipeline processing. For related documentation, see ASP.NET Core Middleware. Of course, running your own Middleware at the very beginning of the pipeline to do this should also work, but if someone accidentally registers another Middleware before yours, problems may occur. Using a custom HttpContextFactory is more reliable.

Next, consider the conversion logic. The W3C TraceContext V1 standard, 3.2 Traceparent Header explains the detailed meaning. Here is an excerpt of the examples it provides.

text
Value = 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
base16(version) = 00
base16(trace-id) = 4bf92f3577b34da6a3ce929d0e0e4736
base16(parent-id) = 00f067aa0ba902b7
base16(trace-flags) = 01  // sampled

Value = 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00
base16(version) = 00
base16(trace-id) = 4bf92f3577b34da6a3ce929d0e0e4736
base16(parent-id) = 00f067aa0ba902b7
base16(trace-flags) = 00  // not sampled

The X-Request-Id we use actually cannot map perfectly to the parent-id here, but at this point, the thing closest to the meaning of parent-id that we can obtain is this value. Typically, the X-Trace-Id and X-Parent-Id we use in our systems are UUIDs. Here, X-Trace-Id happens to meet the requirements for trace-id; both are 32 HEX characters. However, the length of X-Parent-Id far exceeds the requirements for parent-id, so you can consider taking either the first 16 or last 16 hexadecimal characters.

Sample code

csharp
using Microsoft.AspNetCore.Http.Features;
using System.Diagnostics;

namespace Microsoft.Azure.Compute.Specialized.HpcAi..PlatformController.Middlewares
{
    public class HttpContextFactory : IHttpContextFactory
    {
        private readonly DefaultHttpContextFactory _defaultHttpContextFactory;

        public HttpContextFactory(IServiceProvider serviceProvider)
        {
            _defaultHttpContextFactory = new DefaultHttpContextFactory(serviceProvider);
        }

        public HttpContext Create(IFeatureCollection featureCollection)
        {
            var context = _defaultHttpContextFactory.Create(featureCollection);

            // If request header has traceparent, it follows the W3C Trace Context specification.
            // Else we generate traceparent from X--TraceId.
            if (context.Request.Headers.ContainsKey("traceparent"))
            {
                context.Items["Has-W3C-Trace-Context"] = true;
                return context;
            }

            context.Items["Has-W3C-Trace-Context"] = false;

            if (!context.Request.Headers.TryGetValue("X-TraceId", out var traceId))
            {
                return context;
            }

            if (!Guid.TryParse(traceId, CultureInfo.InvariantCulture, out var _))
            {
                return context;
            }

            if (!context.Request.Headers.TryGetValue("X-RequestId", out var requestId))
            {
                return context;
            }

            if (!Guid.TryParse(requestId, CultureInfo.InvariantCulture, out var _))
            {
                return context;
            }

            context.Request.Headers.TraceParent = $"00-{traceId.ToString().Replace("-", string.Empty)}-{requestId.ToString().Replace("-", string.Empty)[15..]}-01";

            return context;
        }

        public void Dispose(HttpContext httpContext)
        {
            _defaultHttpContextFactory.Dispose(httpContext);
        }
    }
}

If you have configured OpenTelemetry correctly, these fields can later be extracted from the TraceId and ParentId in HttpContext.Features.Get<IHttpActivityFeature>()!.Activity.