Context Propagation: .NET Microservices
When a request flows from an API gateway through an order service, inventory service, and payment service, OpenTelemetry's context propagation ensures all spans are linked into a single trace tree. Without propagation, each service would start a separate, unconnected trace. With it, you see the full request path in one visualizable graph.
I once investigated a 5-second latency spike in a microservices system. The API gateway showed fast response times. The order service logged it received the request instantly. Every individual service was responsive. Only after implementing W3C Trace Context propagation and viewing the full trace tree did I discover that the request was queued for 4 seconds waiting for an available database connection in the payment service—invisible until context propagated the span across service boundaries.
Understanding Context Propagation Standards
OpenTelemetry uses industry standards to pass trace context between services. The most common are:
| Standard | Format | Use Case |
|---|---|---|
| W3C Trace Context | traceparent: 00-trace_id-span_id-01 | HTTP, gRPC, all modern protocols |
| Baggage | baggage: order_id=ORD-123, customer_id=CUST-456 | Request-scoped data across services |
| Jaeger Propagator | uber-trace-id: trace_id:span_id:1:sampled | Legacy Jaeger systems; widely compatible |
W3C Trace Context is standard-compliant and recommended for new systems. OpenTelemetry's CompositeTextMapPropagator supports multiple formats simultaneously for compatibility.
Setting Up Automatic HTTP Context Propagation
ASP.NET Core automatically propagates context for incoming HTTP requests. Configure the SDK to extract and inject context:
using OpenTelemetry.Trace;
using OpenTelemetry.Instrumentation.AspNetCore;
var tracerProvider = new TracerProviderBuilder()
.SetResource(resource)
.SetSampler(new TraceIdRatioBasedSampler(0.1))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation(options =>
{
// Automatically set trace context headers on outbound requests
options.SetHttpFlavor = true;
})
.AddBatchSpanProcessor(new BatchSpanProcessorOptions { ... })
.Build();
The AddHttpClientInstrumentation() method automatically injects W3C Trace Context headers (traceparent, tracestate, baggage) into outbound HTTP requests, linking child spans across services.
Propagating Context in HTTP Requests
When your order service calls the inventory service, context flows automatically:
using System.Net.Http;
public class OrderService
{
private readonly HttpClient _httpClient;
private readonly Tracer _tracer;
public OrderService(HttpClient httpClient, TracerProvider tracerProvider)
{
_httpClient = httpClient;
_tracer = tracerProvider.GetTracer("OrderService");
}
public async Task ValidateOrderAsync(Order order)
{
using var span = _tracer.StartSpan("ValidateOrder");
span.SetAttribute("order.id", order.Id);
// Call inventory service; context propagates automatically
var response = await _httpClient.GetAsync(
$"http://inventory-service:8080/stock/{order.Item.Sku}"
);
if (response.IsSuccessStatusCode)
{
span.AddEvent(new("InventoryAvailable"));
}
else
{
span.RecordException(new InvalidOperationException("Inventory check failed"));
span.SetStatus(ActivityStatusCode.Error);
}
}
}
// In the inventory service (listening on :8080):
[ApiController]
[Route("stock")]
public class InventoryController : ControllerBase
{
private readonly Tracer _tracer;
public InventoryController(TracerProvider tracerProvider)
{
_tracer = tracerProvider.GetTracer("InventoryService");
}
[HttpGet("{sku}")]
public IActionResult GetStock(string sku)
{
// ASP.NET Core automatically extracts trace context from the request
// The resulting span becomes a CHILD of the ValidateOrder span
using var span = _tracer.StartSpan("LookupStock");
span.SetAttribute("sku", sku);
var stock = _database.GetStock(sku);
return Ok(new { sku, stock });
}
}
The inventory service receives the traceparent header, extracts the trace ID and parent span ID, and creates a child span under it. The result in Jaeger:
ValidateOrder [Order Service]
└─ HTTP GET /stock/WIDGET-001 [Inventory Service]
└─ LookupStock [Inventory Service]
All three spans share the same trace ID, visible in a single trace view.
Propagating Baggage Across Services
Baggage carries request-scoped data without adding it as HTTP headers. For example, the API gateway might add the customer ID and request ID to baggage:
using System.Diagnostics;
[ApiController]
[Route("orders")]
public class ApiGatewayController : ControllerBase
{
private readonly Tracer _tracer;
public ApiGatewayController(TracerProvider tracerProvider)
{
_tracer = tracerProvider.GetTracer("ApiGateway");
}
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest req)
{
var requestId = Guid.NewGuid().ToString();
var customerId = req.CustomerId;
// Set baggage (automatic propagation)
using var requestIdScope = new BaggageSetterScope("request.id", requestId);
using var customerIdScope = new BaggageSetterScope("customer.id", customerId);
using var span = _tracer.StartSpan("ApiGateway:CreateOrder");
span.SetAttribute("request.id", requestId);
var order = await _orderServiceClient.CreateAsync(req);
return Created($"/orders/{order.Id}", order);
}
}
// Order service (downstream):
public class OrderService
{
private readonly Tracer _tracer;
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
using var span = _tracer.StartSpan("CreateOrder");
// Baggage automatically available; no parameter passing needed
var requestId = ActivityBaggage.GetBaggage("request.id");
var customerId = ActivityBaggage.GetBaggage("customer.id");
span.SetAttribute("request.id", requestId);
span.SetAttribute("customer.id", customerId);
// Process order
}
}
// Helper (simplified; use System.Diagnostics.ActivityBaggage in production)
public class BaggageSetterScope : IDisposable
{
private readonly string _key;
private readonly string _previous;
public BaggageSetterScope(string key, string value)
{
_key = key;
_previous = ActivityBaggage.GetBaggage(key);
ActivityBaggage.SetBaggage(key, value);
}
public void Dispose() => ActivityBaggage.SetBaggage(_key, _previous);
}
Baggage is automatically injected into HTTP headers (via W3C Baggage format) and extracted on the other side, eliminating the need for manual context passing in method parameters.
Propagating Context through Message Queues
For asynchronous communication via RabbitMQ or Kafka, manually extract and inject context:
using OpenTelemetry;
public class OrderPublisher
{
private readonly IConnection _rabbitConnection;
private readonly Tracer _tracer;
private readonly TextMapPropagator _propagator;
public OrderPublisher(TracerProvider tracerProvider)
{
_tracer = tracerProvider.GetTracer("OrderPublisher");
_propagator = new CompositeTextMapPropagator(new TextMapPropagator[]
{
new W3CTraceContextPropagator(),
new BaggagePropagator()
});
}
public async Task PublishOrderAsync(Order order)
{
using var span = _tracer.StartSpan("PublishOrder");
span.SetAttribute("order.id", order.Id);
var message = new RabbitMQ.Client.BasicProperties();
// Inject trace context into message headers
_propagator.Inject(
new PropagationContext(Activity.Current?.Context ?? default, Baggage.GetBaggage()),
message,
(headers, key, value) =>
{
headers.Headers ??= new Dictionary<string, object>();
headers.Headers[key] = Encoding.UTF8.GetBytes(value);
}
);
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(order));
var channel = _rabbitConnection.CreateModel();
channel.BasicPublish("orders", "process", message, body);
}
}
// Order processor (consuming side):
public class OrderProcessor
{
private readonly Tracer _tracer;
private readonly TextMapPropagator _propagator;
public OrderProcessor(TracerProvider tracerProvider)
{
_tracer = tracerProvider.GetTracer("OrderProcessor");
_propagator = new CompositeTextMapPropagator(new TextMapPropagator[]
{
new W3CTraceContextPropagator(),
new BaggagePropagator()
});
}
public async Task ProcessMessageAsync(BasicDeliverEventArgs message)
{
// Extract trace context from message headers
var context = _propagator.Extract(
new PropagationContext(),
message.BasicProperties,
(headers, key) =>
{
var value = headers.Headers?[key] as byte[];
return value != null ? Encoding.UTF8.GetString(value) : null;
}
);
// Create a new span linked to the parent
using var scope = new ActivityScope(context.ActivityContext);
using var span = _tracer.StartSpan("ProcessOrder");
var order = JsonSerializer.Deserialize<Order>(message.Body.Span);
span.SetAttribute("order.id", order.Id);
// Process order
}
}
// Helper: Activates context from message
public class ActivityScope : IDisposable
{
private readonly ActivityContext _previous;
public ActivityScope(ActivityContext context)
{
_previous = Activity.Current?.Context ?? default;
// Set context for this scope
}
public void Dispose() { /* Restore previous context */ }
}
By extracting and injecting context via headers, asynchronous message processing maintains the same trace ID, making async workflows visible in trace diagrams.
Key Takeaways
- W3C Trace Context (
traceparentheader) automatically propagates across HTTP requests when you useAddHttpClientInstrumentation(). - Baggage carries request-scoped metadata without adding headers, reducing overhead.
- ASP.NET Core automatically extracts and creates child spans for incoming requests.
- Message queue consumers must manually extract context from headers to link async operations to traces.
- Composite propagators support multiple formats for backward compatibility.
Frequently Asked Questions
What if a downstream service does not use OpenTelemetry?
Context still propagates—the headers are standard W3C format. The downstream service ignores them if it does not use OpenTelemetry. No errors occur.
Can I propagate context across gRPC services?
Yes. AddGrpcClientInstrumentation() automatically handles it, similar to HTTP.
What is the difference between baggage and span attributes?
Attributes are span-specific metadata. Baggage is request-scoped context passed to all spans in the trace tree. Use baggage for data that all services need (request ID, user ID); use attributes for service-specific details.
Is there a limit to the size of baggage?
W3C Baggage spec recommends keeping it under 8 KB per header. Avoid storing large objects; use IDs and have services query details separately.
Does sampling affect context propagation?
No. Context propagates even if the span is not sampled. This allows downstream services to make independent sampling decisions.