⏱ 13 min read
If you've landed here directly, consider reading Part 1 first. It covers what OpenTelemetry is, what traces, metrics, and logs actually mean, and why the instrumentation/exporter separation matters. This post picks up right where that one ends - concepts assumed, code begins.
Enough theory. Let's get something running.
Add the NuGet packages:
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
Wire it up in Program.cs:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("my-api", serviceVersion: "1.0.0"))
.AddAspNetCoreInstrumentation() // auto-traces every incoming HTTP request
.AddHttpClientInstrumentation() // auto-traces outgoing HttpClient calls
.AddSource("MyApp.Orders") // register your custom ActivitySource
.AddOtlpExporter()) // ship traces to Jaeger or a collector
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation() // GC, thread pool, memory - free signal
.AddMeter("MyApp.Orders") // register your custom Meter
.AddOtlpExporter())
.WithLogging(logging => logging
.AddOtlpExporter());
That's the complete wiring. The ASP.NET Core instrumentation alone gives you traces for every incoming HTTP request and all outgoing HttpClient calls - zero manual code required. Add OpenTelemetry.Instrumentation.SqlClient if you want SQL queries in there too.
Now spin up Jaeger locally:
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
jaegertracing/all-in-one:latest
Open http://localhost:16686. Start your application, hit any endpoint, refresh Jaeger. You'll see your first trace - a complete timeline of what happened inside your API for that single request. That moment is genuinely satisfying.
To configure the OTLP exporter endpoint explicitly:
.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri("http://localhost:4317");
opts.Protocol = OtlpExportProtocol.Grpc;
})
Auto-instrumentation covers the framework-level operations: the HTTP request arrived, the SQL query ran, the HttpClient call went out. Useful - but it knows nothing about your business logic.
A custom span lets you draw your own box around any operation that matters to you. When you start a span, the clock starts. When the using block exits - whether normally or because an exception was thrown - the span closes and its duration is recorded. Everything that happens inside that block - child spans, log lines, events - is linked together by the same TraceId.
You control the name ("PlaceOrder"), the attributes (customer.id, order.id), and the status (OK or ERROR). This is where generic "OpenTelemetry" becomes your telemetry - shaped around the operations that actually matter in your domain.
public class OrderService
{
private static readonly ActivitySource Source = new("MyApp.Orders");
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
public async Task<Order> PlaceOrderAsync(int customerId, int productId)
{
using var activity = Source.StartActivity("PlaceOrder");
activity?.SetTag("customer.id", customerId);
activity?.SetTag("product.id", productId);
try
{
_logger.LogInformation("Placing order for customer {CustomerId}", customerId);
var inventory = await CheckInventoryAsync(productId);
if (!inventory.Available)
{
activity?.SetStatus(ActivityStatusCode.Error, "Out of stock");
activity?.AddEvent(new ActivityEvent("inventory.unavailable"));
throw new InvalidOperationException("Product out of stock");
}
var order = await CreateOrderAsync(customerId, productId);
activity?.SetTag("order.id", order.Id);
return order;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task<InventoryResult> CheckInventoryAsync(int productId)
{
using var activity = Source.StartActivity("CheckInventory");
activity?.SetTag("product.id", productId);
// database call
return new InventoryResult(Available: true);
}
}
A few things worth knowing here:
ActivitySource is static readonly - create it once, reuse it everywhere across the lifetime of the applicationusing on activity ensures the span closes (and its duration is recorded) when the block exits, even on exceptionSetTag become searchable attributes in your backend - filter traces by customer.id or order.id directly in Jaeger's UISetStatus(ActivityStatusCode.Error) marks the span red in the Jaeger UI, making failures immediately obvious in the trace timelineAddEvent adds a timestamped annotation to the span - useful for checkpoints inside a long operation ("inventory checked", "payment initiated")The child span CheckInventory automatically becomes a child of PlaceOrder because OTel tracks the ambient activity context via AsyncLocal. You don't pass parent IDs manually - it just works across await boundaries.
public class OrderMetrics
{
private static readonly Meter Meter = new("MyApp.Orders", "1.0.0");
private readonly Counter<long> _ordersPlaced;
private readonly Counter<long> _ordersFailed;
private readonly Histogram<double> _orderProcessingTime;
public OrderMetrics()
{
_ordersPlaced = Meter.CreateCounter<long>(
"orders.placed",
description: "Total number of orders placed");
_ordersFailed = Meter.CreateCounter<long>(
"orders.failed",
description: "Total number of failed orders");
_orderProcessingTime = Meter.CreateHistogram<double>(
"orders.processing_duration_ms",
unit: "ms",
description: "Time to process an order end-to-end");
}
public void RecordOrderPlaced(string region) =>
_ordersPlaced.Add(1, new KeyValuePair<string, object?>("region", region));
public void RecordOrderFailed(string reason) =>
_ordersFailed.Add(1, new KeyValuePair<string, object?>("reason", reason));
public void RecordProcessingTime(double ms) =>
_orderProcessingTime.Record(ms);
}
Register OrderMetrics as a singleton in DI and inject it into OrderService. Now you have business-level signal - not just "endpoint returned 200" but "347 orders processed in the last minute, 4 failed, p99 processing time was 430ms."
The region parameter on RecordOrderPlaced is an attribute on the metric data point. It lets you slice the counter: "EU orders" vs "US orders". Watch cardinality here - don't use a user ID or request ID as an attribute or you'll create millions of unique time series and make your metrics backend very unhappy.
When you emit a log inside an active span, OTel automatically enriches it with TraceId and SpanId:
public async Task ProcessAsync()
{
using var activity = Source.StartActivity("Process");
// Both log lines automatically carry TraceId and SpanId - no changes needed
_logger.LogInformation("Processing started");
await DoWorkAsync();
_logger.LogInformation("Processing completed");
}
With a structured logger (Serilog, for example), the output looks like:
{
"message": "Processing started",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"timestamp": "2026-04-03T14:32:05.123Z"
}
When you spot an error in your log aggregator (Seq, Elasticsearch, Loki), copy the traceId, paste it into Jaeger's search, and you immediately see everything that was happening in the system when that log line was written. This is the connection between the three pillars that makes observability actually useful - any signal leads you to the others.
The OTel Collector - rather than shipping telemetry directly from your application to a backend, the Collector acts as a pipeline: your app sends to the Collector, the Collector fans out to multiple backends, applies filters, redacts sensitive fields, and batches efficiently. For anything beyond local development, this is the recommended architecture.
Sampling strategies - head-based sampling (decide at trace start) vs tail-based sampling (decide after the full trace is captured, so you can always keep error traces). The OTel Collector supports both. In development, sample 100%. In production, start at 10% and tune from there.
Cloud backends - Azure Monitor has supported OTLP natively since 2023. AWS X-Ray, Google Cloud Trace, Datadog, Dynatrace, New Relic, and Honeycomb all accept OTLP. Switching backends is one changed exporter configuration in Program.cs - your instrumentation code stays untouched.
More library instrumentation - there are OTel packages for Entity Framework Core, Redis, RabbitMQ, gRPC, MassTransit, and more. The pattern is always the same: one AddXInstrumentation() call in your setup.
The OpenTelemetry .NET documentation is thorough and the GitHub examples cover every major scenario.
You don't need to instrument everything on day one.
Start with auto-instrumentation for ASP.NET Core and HttpClient. Get traces into Jaeger. Spend five minutes clicking through a real trace from your application - look at what it already knows about your system without a single line of custom code.
Then add one custom span around the most important operation in your codebase - the thing that, when it is slow, everyone notices. Attach the domain attributes that matter: order IDs, customer IDs, the names of the external services you call.
That single span will pay for itself the first time something breaks at 2am and you can see exactly where to look.
Have you added OpenTelemetry to a .NET project? I'd love to hear what backend you're using and what surprised you. Drop a comment below or reach me on LinkedIn or X.
For more .NET deep-dives on ASP.NET Core, distributed systems, and architecture, check out the rest of the posts on irina.codes.
dotnet opentelemetry observability csharp aspnet-core distributed-tracing