OpenTelemetry in .NET Aspire: Traces, Metrics, and Logs

OpenTelemetry is the industry standard for observability. It defines how applications emit traces, metrics, and logs in a vendor-neutral format. .NET Aspire configures OpenTelemetry for every service in your application automatically through the ServiceDefaults project, and extending it with custom telemetry is straightforward.

What Aspire Configures by Default

The ConfigureOpenTelemetry method in ServiceDefaults sets up three pillars of observability:

Example.cs
public static IHostApplicationBuilder ConfigureOpenTelemetry(
    this IHostApplicationBuilder builder)
{
    builder.Logging.AddOpenTelemetry(logging =>
    {
        logging.IncludeFormattedMessage = true;
        logging.IncludeScopes = true;
    });

    builder.Services.AddOpenTelemetry()
        .WithMetrics(metrics =>
        {
            metrics.AddAspNetCoreInstrumentation()
                .AddHttpClientInstrumentation()
                .AddRuntimeInstrumentation();
        })
        .WithTracing(tracing =>
        {
            tracing.AddAspNetCoreInstrumentation()
                .AddGrpcClientInstrumentation()
                .AddHttpClientInstrumentation();
        });

    builder.AddOpenTelemetryExporters();

    return builder;
}

The OTLP exporter sends all telemetry to the Aspire dashboard during development. In production, you can point it at any OTLP-compatible backend — Jaeger, Grafana Tempo, Azure Monitor, or Datadog.

Understanding Traces

A trace represents a single operation as it flows through your distributed system. Each step is a span. ASP.NET Core instrumentation creates spans automatically for incoming HTTP requests. HttpClient instrumentation creates spans for outgoing HTTP calls. Database drivers like Npgsql create spans for SQL queries.

The result is a complete picture of every request, from ingress to database and back, without writing a single line of instrumentation code.

Adding Custom Traces

When automatic instrumentation is not enough, create custom spans using ActivitySource:

Example.cs
public class OrderService
{
    private static readonly ActivitySource Source = new("MyApp.Orders");

    public async Task<Order> ProcessOrderAsync(CreateOrderRequest request)
    {
        using var activity = Source.StartActivity("ProcessOrder");
        activity?.SetTag("order.item_count", request.Items.Count);
        activity?.SetTag("order.customer_id", request.CustomerId);

        var order = new Order { CustomerId = request.CustomerId };

        using (Source.StartActivity("ValidateInventory"))
        {
            await ValidateInventoryAsync(request.Items);
        }

        using (Source.StartActivity("CalculateTotals"))
        {
            order.Total = CalculateTotals(request.Items);
        }

        using (Source.StartActivity("SaveOrder"))
        {
            await SaveOrderAsync(order);
        }

        activity?.SetTag("order.id", order.Id);
        activity?.SetStatus(ActivityStatusCode.Ok);

        return order;
    }
}

Register your activity source in ServiceDefaults:

Example.cs
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.AddSource("MyApp.Orders");
    });

These custom spans appear in the Aspire dashboard alongside the automatic instrumentation, giving you granular visibility into your business logic.

Understanding Metrics

Metrics are numerical measurements collected over time. Aspire configures three categories by default:

Adding Custom Metrics

Define custom metrics using the System.Diagnostics.Metrics API:

Example.cs
public class OrderMetrics
{
    private readonly Counter<long> _ordersCreated;
    private readonly Histogram<double> _orderValue;
    private readonly UpDownCounter<long> _activeOrders;

    public OrderMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("MyApp.Orders");
        _ordersCreated = meter.CreateCounter<long>(
            "orders.created", "orders", "Total orders created");
        _orderValue = meter.CreateHistogram<double>(
            "orders.value", "GBP", "Order value distribution");
        _activeOrders = meter.CreateUpDownCounter<long>(
            "orders.active", "orders", "Currently processing orders");
    }

    public void OrderCreated(decimal value)
    {
        _ordersCreated.Add(1);
        _orderValue.Record((double)value);
    }

    public void OrderProcessingStarted() => _activeOrders.Add(1);
    public void OrderProcessingCompleted() => _activeOrders.Add(-1);
}

Register the meter in ServiceDefaults:

Example.cs
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics.AddMeter("MyApp.Orders");
    });

Register OrderMetrics as a singleton and inject it wherever you create orders.

Structured Logging

Aspire configures the OpenTelemetry logging provider, which means your standard ILogger calls are captured as structured log records with trace context:

Example.cs
_logger.LogInformation("Order {OrderId} created for customer {CustomerId}",
    order.Id, order.CustomerId);

This log entry automatically includes the trace ID and span ID of the current activity. In the Aspire dashboard, you can click a log entry and jump to the associated trace.

Exporting to Production Backends

The OTLP exporter is configured through environment variables, which means switching from the Aspire dashboard to a production backend requires no code changes:

data.json
{
  "OTEL_EXPORTER_OTLP_ENDPOINT": "https://otel-collector.mycompany.com:4317",
  "OTEL_SERVICE_NAME": "catalog-api"
}

Aspire's OpenTelemetry configuration gives you production-grade observability from day one. The automatic instrumentation covers the common cases, and the standard .NET APIs make custom telemetry simple to add.