OpenTelemetry in .NET — Traces, Metrics, and Logs in One Setup
Instrument your ASP.NET Core app with OpenTelemetry. Auto-instrument HTTP, EF Core, and HttpClient. Export to Jaeger or OTLP. Add custom spans, counters, histograms, and correlate logs with trace IDs.
The Three Pillars
| Pillar | Answers | .NET Type |
|---|---|---|
| Traces | Where did time go? What path did this request take? | Activity / ActivitySource |
| Metrics | How many? How fast? What's the rate? | Meter / Counter / Histogram |
| Logs | What happened? What was the state? | ILogger (enriched with trace context) |
OpenTelemetry ties all three together with a shared TraceId so you can jump from a slow metric to the trace to the log line that explains it.
Install Packages
# Core SDK
dotnet add package OpenTelemetry.Extensions.Hosting
# Instrumentation
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
# Exporters
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # OTLP (Jaeger, Grafana Tempo, etc.)
dotnet add package OpenTelemetry.Exporter.Console # dev/debugFull Setup in Program.cs
var serviceName = "payment-service";
var serviceVersion = "1.0.0";
// Tracing
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName, serviceVersion: serviceVersion)
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] = builder.Environment.EnvironmentName,
["host.name"] = Environment.MachineName
}))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(opts =>
{
opts.RecordException = true;
opts.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation(opts =>
{
opts.RecordException = true;
opts.FilterHttpRequestMessage = req =>
req.RequestUri?.Host != "169.254.169.254"; // skip IMDS
})
.AddEntityFrameworkCoreInstrumentation(opts =>
{
opts.SetDbStatementForText = true;
opts.SetDbStatementForStoredProcedure = true;
})
.AddSource(PaymentService.ActivitySourceName) // custom spans
.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri("http://localhost:4317");
opts.Protocol = OtlpExportProtocol.Grpc;
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation() // GC, thread pool, memory
.AddMeter(PaymentService.MeterName) // custom metrics
.AddOtlpExporter())
.WithLogging(logging => logging
.AddOtlpExporter());
// Enrich ILogger output with TraceId and SpanId
builder.Logging.AddOpenTelemetry(opts =>
{
opts.IncludeFormattedMessage = true;
opts.IncludeScopes = true;
});Custom Spans with ActivitySource
Activity is the .NET equivalent of an OpenTelemetry span.
public class PaymentService(IPaymentRepository repo, ILogger<PaymentService> logger)
{
public static readonly string ActivitySourceName = "PaymentService";
public static readonly string MeterName = "PaymentService";
private static readonly ActivitySource _activitySource = new(ActivitySourceName);
public async Task<PaymentResult> ProcessPaymentAsync(
PaymentRequest request, CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("ProcessPayment");
// Add structured tags to the span
activity?.SetTag("payment.currency", request.Currency);
activity?.SetTag("payment.amount_minor", request.AmountMinor);
activity?.SetTag("payment.merchant_id", request.MerchantId);
try
{
var result = await ChargeAsync(request, ct);
activity?.SetTag("payment.result", result.Status.ToString());
activity?.SetStatus(ActivityStatusCode.Ok);
return result;
}
catch (PaymentDeclinedException ex)
{
activity?.SetTag("payment.decline_reason", ex.Reason);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
// Don't re-record the exception — it's a business outcome, not a bug
throw;
}
catch (Exception ex)
{
activity?.RecordException(ex);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}Child Spans — Nested Activities
Child spans appear nested under the parent in Jaeger/Tempo:
private async Task<ChargeResult> ChargeAsync(PaymentRequest request, CancellationToken ct)
{
using var activity = _activitySource.StartActivity("ChargeCard");
activity?.SetTag("card.last4", request.CardLast4);
// This EF Core query will automatically appear as a child span
var account = await repo.GetAccountAsync(request.AccountId, ct);
using var gatewayActivity = _activitySource.StartActivity("CallPaymentGateway");
gatewayActivity?.SetTag("gateway.provider", "stripe");
var result = await gatewayClient.ChargeAsync(request, ct);
gatewayActivity?.SetTag("gateway.charge_id", result.ChargeId);
return result;
}Custom Metrics with Meter
public class PaymentMetrics : IDisposable
{
private readonly Meter _meter;
// Counter — increases monotonically
private readonly Counter<long> _paymentsProcessed;
// Histogram — records a distribution (latency, sizes)
private readonly Histogram<double> _paymentDuration;
// Observable Gauge — sampled on demand
private readonly ObservableGauge<int> _pendingPayments;
private int _pendingCount;
public PaymentMetrics()
{
_meter = new Meter(PaymentService.MeterName, "1.0.0");
_paymentsProcessed = _meter.CreateCounter<long>(
"payments.processed",
unit: "{payments}",
description: "Total number of payments processed");
_paymentDuration = _meter.CreateHistogram<double>(
"payments.duration",
unit: "ms",
description: "Payment processing duration");
_pendingPayments = _meter.CreateObservableGauge<int>(
"payments.pending",
() => _pendingCount,
unit: "{payments}",
description: "Number of payments currently in processing");
}
public void RecordPayment(string currency, string status, double durationMs)
{
var tags = new TagList
{
{ "currency", currency },
{ "status", status }
};
_paymentsProcessed.Add(1, tags);
_paymentDuration.Record(durationMs, tags);
}
public void SetPending(int count) => _pendingCount = count;
public void Dispose() => _meter.Dispose();
}Register as a singleton:
builder.Services.AddSingleton<PaymentMetrics>();Use in services:
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var result = await ChargeAsync(request, ct);
_metrics.RecordPayment(request.Currency, result.Status.ToString(), sw.Elapsed.TotalMilliseconds);
return result;
}
catch
{
_metrics.RecordPayment(request.Currency, "error", sw.Elapsed.TotalMilliseconds);
throw;
}
}Correlating Logs With Traces
When AddOpenTelemetry() is configured on ILoggerProvider, log entries are automatically enriched with TraceId and SpanId:
{
"Timestamp": "2026-04-14T12:00:00Z",
"Level": "Information",
"Message": "Payment processed for merchant stripe_abc",
"TraceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"SpanId": "00f067aa0ba902b7",
"Properties": {
"merchant_id": "stripe_abc",
"payment.amount_minor": 9900
}
}In Grafana, click a trace span → "View Logs" → all log lines from that span appear filtered by TraceId. Zero configuration.
Exporting to Jaeger (Dev Setup)
# docker-compose.yml
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP// appsettings.Development.json
{
"OTLP": {
"Endpoint": "http://localhost:4317"
}
}Open http://localhost:16686 — your traces appear immediately.
Sampling — Reducing Volume in Production
.WithTracing(tracing => tracing
// ... instrumentation ...
.SetSampler(new TraceIdRatioBasedSampler(0.1)) // sample 10% of traces
// Or: always sample errors regardless of rate
.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1)))
.AddOtlpExporter());Always sample 100% in dev; use ratio sampling in production. Some collectors (Grafana Agent, OpenTelemetry Collector) support tail-based sampling — sample 100% of errored traces, 5% of successful ones.
Activity.Current — Access Trace Context Anywhere
// Add custom tags from deep in the call stack without threading Activity through
public class AuditService
{
public void RecordAudit(string action, string userId)
{
// Enriches the current span — no Activity parameter needed
Activity.Current?.SetTag("audit.action", action);
Activity.Current?.SetTag("audit.user_id", userId);
}
}Key Takeaways
- One
AddOpenTelemetry()call wires up traces, metrics, and logs with a sharedTraceId - ASP.NET Core, HttpClient, and EF Core are auto-instrumented — no manual spans needed for framework code
ActivitySource+Activityfor custom spans;Meter+Counter/Histogramfor custom metrics- OTLP exporter works with Jaeger, Grafana Tempo, Honeycomb, Lightstep, and AWS X-Ray
- Logs get
TraceId/SpanIdautomatically when OpenTelemetry logging is configured - Use
TraceIdRatioBasedSamplerin production — full sampling is expensive at scale
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.