.NET & C# Development · Lesson 158 of 229
Distributed Tracing in .NET — OpenTelemetry and Correlation IDs
Distributed Tracing in .NET — OpenTelemetry and Correlation IDs
When a single user request flows through five microservices, you need distributed tracing to see the full picture. This guide sets up OpenTelemetry tracing in .NET from scratch.
Why Distributed Tracing?
Without tracing:
Request fails in Service D — you check Service D logs
Error says "dependency failed" — which dependency? Which call?
You grep logs across 5 services by timestamp — painful, imprecise
With tracing:
One trace ID follows the request through all services
Waterfall view shows exactly where time was spent
Each span has attributes: SQL query, HTTP URL, queue name, error detailsCore Concepts
Trace: the end-to-end journey of one request across all services
Span: one unit of work within a trace (HTTP call, DB query, queue publish)
TraceId: 128-bit ID shared by all spans in the same trace
SpanId: 64-bit ID unique to each span
Parent: each span knows its parent span — forms a tree
W3C TraceContext (traceparent header):
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
── ──────────────────────────────── ──────────────── ──
ver TraceId (32 hex chars) SpanId flagsStep 1: Install OpenTelemetry Packages
<!-- Api.csproj -->
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.*" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.*" />
<!-- For Jaeger: -->
<PackageReference Include="OpenTelemetry.Exporter.Jaeger" Version="1.*" />
<!-- For Azure Monitor: -->
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.*" />Step 2: Configure OpenTelemetry
// Program.cs
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(
serviceName: "OrderService",
serviceVersion: "1.0.0"))
.WithTracing(tracing => tracing
// Auto-instrument ASP.NET Core (incoming HTTP requests)
.AddAspNetCoreInstrumentation(opts =>
{
// Enrich spans with request/response details
opts.EnrichWithHttpRequest = (activity, req) => activity.SetTag("http.request_id", req.Headers.RequestId.FirstOrDefault());
opts.EnrichWithHttpResponse = (activity, resp) => activity.SetTag("http.response_size", resp.ContentLength);
opts.Filter = ctx => ctx.Request.Path != "/health"; // skip health checks
})
// Auto-instrument outgoing HttpClient calls
.AddHttpClientInstrumentation()
// Auto-instrument Entity Framework / SQL
.AddSqlClientInstrumentation(opts => opts.SetDbStatementForText = true)
// Your own ActivitySource for custom spans
.AddSource("OrderService.*")
// Export to Jaeger (dev) or OTLP (prod)
.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri(builder.Configuration["Otel:Endpoint"]!);
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter());Step 3: Custom Spans for Business Operations
// Define ActivitySource once — share across the service
public static class Telemetry
{
public static readonly ActivitySource Orders = new("OrderService.Orders", "1.0");
}
// Use in a handler
public class CreateOrderHandler(IOrderRepository repo, IEventBus bus)
: IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
// Start a custom span — child of the incoming HTTP span
using var activity = Telemetry.Orders.StartActivity("CreateOrder");
activity?.SetTag("order.customer_id", cmd.CustomerId);
activity?.SetTag("order.item_count", cmd.Items.Count);
try
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
await repo.AddAsync(order, ct);
// Nested span for event publishing
using var publishActivity = Telemetry.Orders.StartActivity("PublishOrderCreated");
await bus.PublishAsync(new OrderCreatedEvent(order.Id), ct);
publishActivity?.SetTag("order.id", order.Id);
activity?.SetTag("order.id", order.Id);
return order.Id;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}Step 4: Propagate Trace Context to Background Jobs
// When publishing a message: attach trace context
public class RabbitMqEventBus(IModel channel) : IEventBus
{
public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
{
var props = channel.CreateBasicProperties();
props.Headers = new Dictionary<string, object?>();
// Inject W3C TraceContext into message headers
var propagator = Propagators.DefaultTextMapPropagator;
propagator.Inject(
new PropagationContext(Activity.Current?.Context ?? default, Baggage.Current),
props.Headers,
(headers, key, value) => headers[key] = value);
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
channel.BasicPublish("", typeof(T).Name, props, body);
}
}
// When consuming a message: extract trace context → continue the trace
public class OrderCreatedConsumer(IMediator mediator)
{
public async Task ConsumeAsync(BasicDeliverEventArgs args, CancellationToken ct)
{
// Extract trace context from message headers
var parentContext = Propagators.DefaultTextMapPropagator.Extract(
default,
args.BasicProperties.Headers,
(headers, key) => headers.TryGetValue(key, out var val)
? [Encoding.UTF8.GetString((byte[])val!)]
: []);
// Start span as a child of the publisher's span
using var activity = Telemetry.Orders.StartActivity(
"ProcessOrderCreated",
ActivityKind.Consumer,
parentContext.ActivityContext);
var @event = JsonSerializer.Deserialize<OrderCreatedEvent>(args.Body.Span)!;
await mediator.Publish(@event, ct);
}
}Step 5: Correlation ID in Structured Logs
// Middleware: ensure every request has a correlation ID in logs
public class CorrelationIdMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx)
{
// Use the OpenTelemetry TraceId as the correlation ID (already propagated)
var traceId = Activity.Current?.TraceId.ToString()
?? ctx.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString("N");
ctx.Response.Headers["X-Correlation-Id"] = traceId;
using (LogContext.PushProperty("CorrelationId", traceId))
using (LogContext.PushProperty("TraceId", Activity.Current?.TraceId.ToString()))
using (LogContext.PushProperty("SpanId", Activity.Current?.SpanId.ToString()))
{
await next(ctx);
}
}
}
// Register before other middleware
app.UseMiddleware<CorrelationIdMiddleware>();// Serilog with OpenTelemetry enrichment — all logs include TraceId automatically
Log.Logger = new LoggerConfiguration()
.Enrich.WithProperty("Service", "OrderService")
.Enrich.FromLogContext() // picks up CorrelationId from middleware
.WriteTo.Console(new JsonFormatter())
.CreateLogger();
// Log output includes:
// { "TraceId": "4bf92f3577b34da6a3ce929d0e0e4736", "SpanId": "00f067aa0ba902b7",
// "CorrelationId": "4bf92f3577b34da6a3ce929d0e0e4736", "message": "Order created" }Viewing Traces
Local development — Jaeger:
docker run -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one
Configure OTLP endpoint: http://localhost:4317
View traces: http://localhost:16686
Production:
Azure Monitor / Application Insights:
builder.Services.AddOpenTelemetry()
.UseAzureMonitor(opts => opts.ConnectionString = "InstrumentationKey=...");
Grafana + Tempo:
.AddOtlpExporter(opts => opts.Endpoint = new Uri("http://tempo:4317"))MediatR Pipeline Behaviour for Automatic Tracing
// Auto-trace every MediatR command/query without adding Activity to every handler
public class TracingBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var operationName = typeof(TRequest).Name;
using var activity = Telemetry.Orders.StartActivity(operationName);
activity?.SetTag("mediator.request", operationName);
try
{
var response = await next();
return response;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}
// Register in DI — runs for every handler
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TracingBehaviour<,>));Interview Answer
"Distributed tracing tracks a single request across multiple microservices using a shared TraceId. .NET uses OpenTelemetry — the CNCF standard — with auto-instrumentation packages for ASP.NET Core, HttpClient, and EF Core. The W3C traceparent header carries the TraceId and SpanId between services; HttpClient instrumentation injects and extracts it automatically. For custom operations, define an ActivitySource and call StartActivity — the span is automatically attached to the current trace. For message queues, manually inject context when publishing and extract when consuming using Propagators.DefaultTextMapPropagator. Logs should include the TraceId via Serilog's LogContext so a single correlation ID links traces in Jaeger to log lines in Elasticsearch. Local development: Jaeger all-in-one via Docker. Production: Azure Monitor, Grafana Tempo, or any OTLP-compatible backend."