Skills configuring-opentelemetry-dotnet
Configure OpenTelemetry distributed tracing, metrics, and logging in ASP.NET Core using the .NET OpenTelemetry SDK. Use when adding observability, setting up OTLP exporters, creating custom metrics/spans, or troubleshooting distributed trace correlation.
install
source · Clone the upstream repo
git clone https://github.com/dotnet/skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/dotnet/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/dotnet-aspnet/skills/configuring-opentelemetry-dotnet" ~/.claude/skills/dotnet-skills-configuring-opentelemetry-dotnet && rm -rf "$T"
manifest:
plugins/dotnet-aspnet/skills/configuring-opentelemetry-dotnet/SKILL.mdsource content
Configuring OpenTelemetry in .NET
When to Use
- Adding distributed tracing to an ASP.NET Core application
- Setting up OpenTelemetry exporters (OTLP is the primary protocol; Jaeger accepts OTLP natively; Prometheus OTLP ingestion requires explicit opt-in)
- Creating custom metrics or trace spans for business operations
- Troubleshooting distributed trace context propagation across services
When Not to Use
- The user wants application-level logging only (use ILogger, Serilog)
- The user is using Application Insights SDK directly (different API)
- The user needs APM with a commercial vendor's proprietary SDK
Inputs
| Input | Required | Description |
|---|---|---|
| ASP.NET Core project | Yes | The application to instrument |
| Observability backend | No | Where to export: OTLP collector, Aspire dashboard, Jaeger (accepts OTLP natively) |
Workflow
Step 1: Install the correct packages
There are many OpenTelemetry NuGet packages. Install exactly these:
# Core SDK + ASP.NET Core instrumentation + logging integration dotnet add package OpenTelemetry.Extensions.Hosting dotnet add package OpenTelemetry.Instrumentation.AspNetCore dotnet add package OpenTelemetry.Instrumentation.Http # Exporter dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # OTLP exporter for traces, metrics, AND logs # Optional — dev/local debugging only (do NOT include in production deployments) # dotnet add package OpenTelemetry.Exporter.Console
Do NOT install
alone — you need OpenTelemetry
OpenTelemetry.Extensions.Hosting for proper DI integration.
Optional: additional auto-instrumentation packages
Install only the packages that match the libraries your application uses:
dotnet add package OpenTelemetry.Instrumentation.SqlClient # SQL Server queries dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore # EF Core dotnet add package OpenTelemetry.Instrumentation.GrpcNetClient # gRPC calls dotnet add package OpenTelemetry.Instrumentation.Runtime # GC, thread pool metrics
Step 2: Configure all signals in Program.cs
using OpenTelemetry.Resources; using OpenTelemetry.Trace; using OpenTelemetry.Metrics; using OpenTelemetry.Logs; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource .AddService(serviceName: builder.Environment.ApplicationName)) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(options => { // Filter out health check endpoints from traces options.Filter = httpContext => !httpContext.Request.Path.StartsWithSegments("/healthz"); }) .AddHttpClientInstrumentation(options => { options.RecordException = true; }) // Optional: add SQL instrumentation if using SqlClient directly // .AddSqlClientInstrumentation(options => // { // options.SetDbStatementForText = true; // options.RecordException = true; // }) // Custom activity sources (must match ActivitySource names in your code) .AddSource("MyApp.Orders") .AddSource("MyApp.Payments") .AddSource("MyApp.Messaging")) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() // Optional: .AddRuntimeInstrumentation() for GC and thread pool metrics // (requires OpenTelemetry.Instrumentation.Runtime package) // Custom meters (must match Meter names in your code) .AddMeter("MyApp.Metrics")) .WithLogging(logging => { logging.IncludeScopes = true; // logging.IncludeFormattedMessage = true; // Enable if you need the formatted message string in log exports }) // Single OTLP exporter for all signals — reads OTEL_EXPORTER_OTLP_ENDPOINT // env var (defaults to http://localhost:4317). Override via environment variable // or appsettings.json configuration. .UseOtlpExporter();
Step 3: Understanding log–trace correlation
The
.WithLogging() call in Step 2 integrates ILogger with OpenTelemetry:
- Each log entry automatically includes TraceId and SpanId for correlation with traces
- The service resource from
propagates to logs automatically.ConfigureResource()
applies to logs alongside traces and metricsUseOtlpExporter()- No additional packages or separate
call neededSetResourceBuilder
Step 4: Create custom spans (Activities) for business operations
using System.Diagnostics; using Microsoft.Extensions.Logging; public class OrderService { // Create an ActivitySource matching what you registered in Step 2 private static readonly ActivitySource ActivitySource = new("MyApp.Orders"); private readonly ILogger<OrderService> _logger; public OrderService(ILogger<OrderService> logger) => _logger = logger; public async Task<Order> ProcessOrderAsync(CreateOrderRequest request) { // Start a new span using var activity = ActivitySource.StartActivity("ProcessOrder"); // Add attributes (tags) to the span activity?.SetTag("order.customer_id", request.CustomerId); activity?.SetTag("order.item_count", request.Items.Count); try { // Child span for validation using (var validationActivity = ActivitySource.StartActivity("ValidateOrder")) { await ValidateOrderAsync(request); validationActivity?.SetTag("validation.result", "passed"); } // Child span for payment using (var paymentActivity = ActivitySource.StartActivity("ProcessPayment", ActivityKind.Client)) // Client = outgoing call { paymentActivity?.SetTag("payment.method", request.PaymentMethod); await ProcessPaymentAsync(request); } var order = new Order { Id = Guid.NewGuid(), CustomerId = request.CustomerId, Status = "Completed" }; activity?.SetTag("order.status", "completed"); activity?.SetStatus(ActivityStatusCode.Ok); return order; } catch (Exception ex) { activity?.SetStatus(ActivityStatusCode.Error, ex.Message); // Log via ILogger — OpenTelemetry captures this with trace correlation. // Prefer logging over activity.RecordException() as OTel is deprecating // span events for exception recording in favor of log-based exceptions. _logger.LogError(ex, "Order processing failed for customer {CustomerId}", request.CustomerId); throw; } } }
Critical:
name must match ActivitySource
in configuration. Unmatched sources are silently ignored — this is the #1 debugging issue.AddSource("...")
Step 5: Create custom metrics
Use
IMeterFactory (injected via DI) to create meters — this ensures proper lifetime management and testability.
using System.Diagnostics; using System.Diagnostics.Metrics; public class OrderMetrics { private readonly Counter<long> _ordersProcessed; private readonly Histogram<double> _orderProcessingDuration; private readonly UpDownCounter<int> _activeOrders; public OrderMetrics(IMeterFactory meterFactory) { // Meter name must match AddMeter("...") in configuration var meter = meterFactory.Create("MyApp.Metrics"); // Counter — use for things that only go up _ordersProcessed = meter.CreateCounter<long>( "orders.processed", "orders", "Total orders successfully processed"); // Histogram — use for measuring distributions (latency, sizes) _orderProcessingDuration = meter.CreateHistogram<double>( "orders.processing_duration", "ms", "Time to process an order"); // UpDownCounter — use for things that go up AND down _activeOrders = meter.CreateUpDownCounter<int>( "orders.active", "orders", "Currently processing orders"); } public void RecordOrderProcessed(string region, double durationMs) { // Tags enable dimensional filtering (by region, status, etc.) var tags = new TagList { { "region", region }, { "order.type", "standard" } }; _ordersProcessed.Add(1, tags); _orderProcessingDuration.Record(durationMs, tags); } }
Register
OrderMetrics in DI:
builder.Services.AddSingleton<OrderMetrics>();
Step 6: Configure context propagation for distributed scenarios
Trace context propagation is automatic for HTTP calls when using
AddHttpClientInstrumentation(). For non-HTTP scenarios:
using System; using System.Collections.Generic; using System.Diagnostics; using OpenTelemetry.Context.Propagation; // ActivitySource should be static — register via .AddSource("MyApp.Messaging") in Step 2 private static readonly ActivitySource MessageSource = new("MyApp.Messaging"); // Manual context propagation (e.g., across message queues) // On the SENDING side: var propagator = Propagators.DefaultTextMapPropagator; var activityContext = Activity.Current?.Context ?? default; var context = new PropagationContext(activityContext, Baggage.Current); var carrier = new Dictionary<string, string>(); propagator.Inject(context, carrier, (dict, key, value) => dict[key] = value); // Send carrier dictionary as message headers // On the RECEIVING side: var parentContext = propagator.Extract(default, carrier, (dict, key) => dict.TryGetValue(key, out var value) ? new[] { value } : Array.Empty<string>()); Baggage.Current = parentContext.Baggage; using var activity = MessageSource.StartActivity("ProcessMessage", ActivityKind.Consumer, parentContext.ActivityContext); // Links to parent trace!
Validation
- Traces appear in the observability backend (Jaeger, Aspire dashboard, etc.)
- HTTP requests automatically create spans with correct verb, URL, status code
- Custom
names matchActivitySource
registrationsAddSource() - Custom
names matchMeter
registrationsAddMeter() - Logs include TraceId and SpanId for correlation
- Health check endpoints are filtered from traces
- Exception details appear on error spans
Common Pitfalls
| Pitfall | Solution |
|---|---|
returns null | Source name doesn't match any — names must match exactly |
| Traces not appearing in exporter | Check OTLP endpoint: gRPC uses port 4317, HTTP uses 4318 |
| Missing HTTP client spans | Ensure is registered; it works for both /DI and (use for lifetime management) |
| High cardinality tags | Don't use user IDs, request IDs, or UUIDs as metric tags — explodes storage |
| OTLP gRPC vs HTTP mismatch | Default is gRPC (port 4317); if collector only accepts HTTP, set |
/ lifecycle | should be static; create via from DI (not ) for proper lifetime management and testability |