Distributed Tracing with OpenTelemetry
Wire OpenTelemetry tracing into an API: ASP.NET Core and HttpClient spans, your own ActivitySource, sampling and OTLP export.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("orders-api"))
.WithTracing(t => t.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation().AddSource("Shop.Checkout").AddOtlpExporter());A trace is a tree of spans that share one trace id. In .NET a span is a System.Diagnostics.Activity created from an ActivitySource. That API is in the runtime, so ASP.NET Core, HttpClient, Npgsql and your own code emit activities without referencing OpenTelemetry. The OpenTelemetry SDK is a listener: it subscribes to named sources, samples, enriches and exports.
Wiring
builder.Services.AddOpenTelemetry() registers the SDK with the host. ConfigureResource(r => r.AddService("orders-api")) sets service.name; without it backends show unknown_service plus the process name and every service blurs together. WithTracing adds sources: AddAspNetCoreInstrumentation() gives one server span per request named after the route template (POST /orders) with semantic-convention tags such as http.request.method, http.route and http.response.status_code; AddHttpClientInstrumentation() gives a client span per outgoing call; AddSource("Shop.Checkout") subscribes to your own source. Database clients ship their own instrumentation (Npgsql has AddNpgsql() in Npgsql.OpenTelemetry).
Custom spans
Create one static ActivitySource per component, named like a namespace. StartActivity returns null when no listener is subscribed to that source, which is why tracing costs almost nothing when off and why every call uses activity?.. When the sampler drops the trace you still get an Activity (so trace context keeps propagating), but IsAllDataRequested is false: guard expensive tag computation with if (activity?.IsAllDataRequested == true). Add a span for work with its own latency or failure story (reserving stock, authorising a payment, publishing a message), not for every method. Put identifiers in tags (order.id), never in span names, and record failures with SetStatus(ActivityStatusCode.Error, ...) plus AddException.
Sampling
| Sampler | Effect | When |
|---|---|---|
ParentBased(AlwaysOn) (default) | Keep every new trace; follow the caller's decision | Development, low traffic |
ParentBased(TraceIdRatioBased(0.1)) | Keep 10% of new traces, decided from the trace id | High traffic, cost control |
| Tail sampling in a Collector | Decide after the trace ends: keep errors and slow traces | When rare failures matter most |
Keep the sampler parent-based in every service. If each service samples independently, traces arrive with holes.
Export
AddOtlpExporter() sends OTLP over gRPC to http://localhost:4317 by default. Configure it with the standard environment variables, not code: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL (grpc or http/protobuf), OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES. Spans go through a batch processor: a 2048-span queue, exported every 5 seconds in batches of up to 512. When the backend is unreachable the queue fills and new spans are dropped while requests carry on. Most teams send to an OpenTelemetry Collector beside the app, so retries, tail sampling and the vendor choice live outside the code.
Production failure modes
- A source missing from
AddSource: no error, the spans just never exist. - Ids in span names (
reserve 10482): backends group by name and cardinality explodes. - Health probes traced at 100%: filter them, or they dominate storage.
- Tags holding emails or card data: traces are kept and shared more widely than the database.
Example
using System.Diagnostics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: "orders-api",
serviceVersion: typeof(Checkout).Assembly.GetName().Version?.ToString() ?? "0.0.0"))
.WithTracing(tracing => tracing
.AddSource(Checkout.ActivitySourceName)
.AddAspNetCoreInstrumentation(options =>
{
options.Filter = context => !context.Request.Path.StartsWithSegments("/health");
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.25)))
.AddOtlpExporter()); // endpoint and protocol come from OTEL_EXPORTER_OTLP_* variables
builder.Services.AddHttpClient<PaymentsClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"] ?? "http://payments:8080"));
builder.Services.AddScoped<Checkout>();
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/health/ready");
app.MapPost("/orders", async (PlaceOrder order, Checkout checkout, CancellationToken ct) =>
{
var orderId = await checkout.PlaceAsync(order, ct);
return TypedResults.Created($"/orders/{orderId}", new { orderId });
});
app.Run();
public sealed record PlaceOrder(string CustomerId, string Sku, int Quantity, decimal Total);
public sealed class PaymentsClient(HttpClient http)
{
public async Task AuthoriseAsync(string customerId, decimal amount, CancellationToken ct)
{
using var response = await http.PostAsJsonAsync("/authorisations", new { customerId, amount, currency = "EUR" }, ct);
response.EnsureSuccessStatusCode();
}
}
public sealed class Checkout(PaymentsClient payments, ILogger<Checkout> logger)
{
public const string ActivitySourceName = "Shop.Checkout";
private static readonly ActivitySource Source = new(ActivitySourceName, "1.0.0");
private static int _nextOrderId = 10_480;
public async Task<int> PlaceAsync(PlaceOrder order, CancellationToken ct)
{
var orderId = Interlocked.Increment(ref _nextOrderId);
using var activity = Source.StartActivity("checkout.place_order");
activity?.SetTag("order.id", orderId);
activity?.SetTag("order.item_count", order.Quantity);
try
{
using (var reserve = Source.StartActivity("inventory.reserve"))
{
reserve?.SetTag("product.sku", order.Sku);
await Task.Delay(15, ct); // stands in for the stock reservation query
}
await payments.AuthoriseAsync(order.CustomerId, order.Total, ct);
activity?.AddEvent(new ActivityEvent("payment.authorised"));
logger.LogInformation("Order {OrderId} placed for {Total}", orderId, order.Total);
return orderId;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
throw;
}
}
}
When to use it
- See that a slow POST /orders spends 1.8 s of its 2 s waiting on the payment provider span rather than in the database.
- Export every trace (ParentBased(AlwaysOn)) to the Collector and let tail sampling keep all error and slow traces plus 25% of the rest.
- Follow an order from the API request through a RabbitMQ message into the fulfilment worker as one trace.
More examples
What the SDK does underneath: an ActivityListener
This is the mechanism OpenTelemetry builds on: <code>AddSource</code> becomes a <code>ShouldListenTo</code> match and the configured sampler becomes the <code>Sample</code> callback. The first <code>StartActivity</code> returns <code>null</code> because no listener exists, which is why instrumented code must use <code>?.</code>. The child picks up the parent from <code>Activity.Current</code>, an <code>AsyncLocal</code>, so nesting works across <code>await</code> without passing anything.
using System.Diagnostics;
var source = new ActivitySource("Shop.Inventory");
// Nobody is listening yet: no allocation, no span.
using (var first = source.StartActivity("inventory.reserve"))
Console.WriteLine($"without a listener: {(first is null ? "null" : "created")}");
using var listener = new ActivityListener
{
ShouldListenTo = s => s.Name == "Shop.Inventory",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = a => Console.WriteLine(
$"stopped {a.DisplayName} trace={a.TraceId} span={a.SpanId} parent={a.ParentSpanId} {a.Duration.TotalMilliseconds:F0} ms"),
};
ActivitySource.AddActivityListener(listener);
using (var parent = source.StartActivity("checkout.place_order"))
{
parent?.SetTag("order.id", 10482);
using var child = source.StartActivity("inventory.reserve");
child?.SetTag("product.sku", "SKU-1042");
await Task.Delay(20);
}
OTLP settings as Kubernetes environment variables
Keeping the endpoint out of code means the same image exports to the Aspire dashboard locally, a staging collector and the production collector. <code>OTEL_RESOURCE_ATTRIBUTES</code> adds attributes to every span, metric and log, and the <code>$(POD_NAME)</code> reference works because <code>POD_NAME</code> is defined earlier in the same list. Use <code>http/protobuf</code> with port 4318 when a proxy between the app and the collector does not pass HTTP/2.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: orders-api
image: registry.example.com/shop/orders-api:1.4.2
env:
- name: OTEL_SERVICE_NAME
value: orders-api
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.observability:4317
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: grpc
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: OTEL_RESOURCE_ATTRIBUTES
value: deployment.environment.name=production,k8s.pod.name=$(POD_NAME)
Continuing a trace through a message queue
HTTP propagation is automatic, but a message sits in a queue with no current activity on the consumer side, so the trace context must travel in the message headers. <code>DistributedContextPropagator.Current</code> is the same W3C propagator ASP.NET Core and <code>HttpClient</code> use, so the consumer span lands in the original trace. The producer span wraps the actual send, so it measures the broker call. Add <code>Shop.Messaging</code> with <code>AddSource</code>. SQS message attributes are strings; RabbitMQ and Kafka headers are bytes, so encode and decode UTF-8 in the setter and getter (RabbitMQ.Client 7 can also propagate context through its own instrumentation).
using System.Diagnostics;
public sealed record OrderPlacedMessage(int OrderId, decimal Total, Dictionary<string, string> Headers);
public static class OrderMessaging
{
public static readonly ActivitySource Source = new("Shop.Messaging");
public static async Task PublishAsync(int orderId, decimal total, Func<OrderPlacedMessage, Task> send)
{
var headers = new Dictionary<string, string>();
// The producer span wraps the broker call, so it records the publish latency and failures.
using var activity = Source.StartActivity("orders publish", ActivityKind.Producer);
activity?.SetTag("messaging.destination.name", "orders");
// Writes traceparent (and tracestate, baggage) for whichever activity is current.
DistributedContextPropagator.Current.Inject(activity ?? Activity.Current, headers,
static (carrier, key, value) => ((Dictionary<string, string>)carrier!)[key] = value);
await send(new OrderPlacedMessage(orderId, total, headers));
}
public static async Task HandleAsync(OrderPlacedMessage message, Func<int, Task> process)
{
DistributedContextPropagator.Current.ExtractTraceIdAndState(message.Headers,
static (object? carrier, string name, out string? value, out IEnumerable<string>? values) =>
{
values = null;
((Dictionary<string, string>)carrier!).TryGetValue(name, out value);
},
out var traceParent, out var traceState);
ActivityContext.TryParse(traceParent, traceState, out var parent);
using var activity = Source.StartActivity("orders process", ActivityKind.Consumer, parent);
activity?.SetTag("messaging.destination.name", "orders");
activity?.SetTag("order.id", message.OrderId);
await process(message.OrderId);
}
}
Discussion