Distributed Trace Context Propagation in C

Distributed telemetry tracing allows developers to monitor, debug, and understand the path of a request as it navigates complex multi-service architectures. This article defines the fundamentals of telemetry tracing and demonstrates how to reliably propagate distributed trace contexts across outbound HTTP calls in modern C# and .NET applications using native diagnostic APIs.

What is Telemetry Tracing?

Telemetry tracing—often called distributed tracing—is the process of recording the lifecycle of an execution path across disparate systems, services, and network boundaries. While traditional logging records isolated events within a single application, distributed tracing stitches together the entire journey of an end-to-end operation.

A distributed trace consists of one or more "spans."

  • Trace: Represents the entire transaction across all systems from start to finish. It shares a globally unique identifier (TraceId).
  • Span: Represents a single unit of contiguous work within that trace (e.g., an HTTP request, a database query, or an internal method execution). Each span has its own identifier (SpanId), a start time, a duration, and a reference to its parent span.

By linking these spans together in a parent-child hierarchy, tracing tools visualize the exact execution flow, identify performance bottlenecks, and pinpoint where errors originate in a microservice ecosystem.

The Role of Trace Context Propagation

For tracing to work across network calls, services must pass the trace state to downstream systems. This is achieved via Trace Context Propagation.

Most modern platforms adhere to the W3C Trace Context specification, which relies on standardized HTTP headers:

  • traceparent: Encodes the version, TraceId, ParentSpanId, and trace flags (e.g., whether the request is sampled).
    • Example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
  • tracestate: Carries vendor-specific routing and filtering metadata as opaque key-value pairs.

Automatic Trace Propagation in .NET

In C# and modern .NET (Core 3.1 and .NET 5+), the runtime natively supports the W3C Trace Context specification via the System.Diagnostics.Activity API, which maps directly to the OpenTelemetry Span concept.

When using standard .NET components, context propagation over HTTP occurs automatically:

  1. An inbound request to an ASP.NET Core application creates an Activity (extracting the traceparent header if present).
  2. Any outbound HTTP request made using HttpClient automatically inspects the ambient Activity.Current.
  3. If an active activity exists, HttpClient generates a new child span ID and automatically injects the traceparent and tracestate headers into the outgoing request.

Basic Setup Example

using System.Diagnostics;

// 1. Define an ActivitySource for creating custom spans
var activitySource = new ActivitySource("OrderService");

// 2. Start a parent span
using (var activity = activitySource.StartActivity("ProcessOrder"))
{
    // The activity is now ambient (Activity.Current is populated)
    using var httpClient = new HttpClient();

    // 3. Outgoing call: HttpClient automatically injects W3C headers
    var response = await httpClient.GetAsync("https://api.inventory.local/check-stock");
    
    // The receiving service will extract the header and continue the same TraceId
    Console.WriteLine($"Dispatched request with TraceId: {activity?.TraceId}");
}

Manual Context Propagation Using DistributedContextPropagator

In scenarios where HttpClient cannot be used directly, or when working with custom message brokers, raw sockets, or third-party HTTP wrappers, you must inject the context manually.

.NET provides the DistributedContextPropagator class to handle standards-compliant context injection and extraction.

Manually Injecting Trace Headers

using System.Diagnostics;

var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.inventory.local/check-stock");

// Retrieve the default W3C propagator
var propagator = DistributedContextPropagator.Current;

// Inject the current ambient Activity into the HttpRequestMessage headers
propagator.Inject(Activity.Current, request, (carrier, key, value) =>
{
    if (carrier is HttpRequestMessage req)
    {
        req.Headers.TryAddWithoutValidation(key, value);
    }
});

var response = await client.SendAsync(request);

Manually Extracting Trace Headers on the Receiving End

When writing a custom receiver (such as a generic TCP server or a custom HTTP listener), use ExtractTraceIdAndState to read the headers and initialize the next Activity:

using System.Diagnostics;

// Simulated incoming header retrieval
string? incomingTraceParent = requestHeaders["traceparent"];
string? incomingTraceState = requestHeaders["tracestate"];

var propagator = DistributedContextPropagator.Current;

// Extract parent context
propagator.ExtractTraceIdAndState(
    requestHeaders,
    getter: (carrier, key) => carrier.TryGetValue(key, out var val) ? new[] { val } : null,
    out string? traceParent,
    out string? traceState);

// Start child activity linked to the incoming trace context
var activitySource = new ActivitySource("InventoryService");
using var activity = activitySource.StartActivity(
    "CheckStock", 
    ActivityKind.Server, 
    parentContext: ActivityContext.Parse(traceParent, traceState));

Summary

Modern C# applications leverage the System.Diagnostics.Activity library to achieve standards-based distributed tracing. For standard HTTP operations, HttpClient handles W3C header injection automatically whenever an ambient Activity is active. For non-standard or decoupled transport mechanisms, DistributedContextPropagator provides explicit programmatic control over the injection and extraction of trace contexts.