Learnixo

.NET Aspire · Lesson 4 of 5

Observability with the .NET Aspire Dashboard

Observability from AddServiceDefaults

C#
// ServiceDefaults wires OpenTelemetry for all services in one place

// In Extensions/ServiceDefaultsExtensions.cs (generated by Aspire template):
public static IHostApplicationBuilder AddServiceDefaults(
    this IHostApplicationBuilder builder)
{
    builder.ConfigureOpenTelemetry();
    builder.AddDefaultHealthChecks();
    builder.Services.AddServiceDiscovery();
    builder.Services.ConfigureHttpClientDefaults(http =>
    {
        http.AddStandardResilienceHandler();
        http.AddServiceDiscovery();
    });

    return builder;
}

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()
                .AddHttpClientInstrumentation());

    // AddOpenTelemetryExporters wires OTLP exporter to Aspire Dashboard locally
    builder.AddOpenTelemetryExporters();

    return builder;
}

Aspire Dashboard — Local Observability

Aspire Dashboard (http://localhost:15888) provides:

Traces:
  → Each incoming request to any service creates a trace
  → Trace spans: HTTP handler → MediatR handler → EF Core SQL → HTTP to upstream
  → End-to-end: "Nurse submits prescription → PrescriptionService → PatientService → DB"
  → See total duration, breakdown per span, errors in red

Structured Logs:
  → All service logs in one view — no switching between terminal windows
  → Filter by: service, severity (Error, Warning, Info), time range
  → Search log message content
  → Each log entry links to its trace (by traceId)

Metrics:
  → HTTP request rate, error rate per service
  → ASP.NET Core: requests/sec, response time histogram
  → Runtime: GC pressure, thread pool, memory
  → Custom business metrics (prescription approvals, INR checks)

Resources:
  → All running services, containers, and infrastructure
  → Port assignments, health status, environment variables

Adding Custom Traces

C#
// ActivitySource for clinical domain tracing
// Register once per assembly
public static class ClinicalTelemetry
{
    public static readonly ActivitySource Source =
        new ActivitySource("SystemForge.Clinical.Prescriptions");
}

// In handler — add a custom span to the distributed trace
public sealed class ApprovePrescriptionHandler
    : IRequestHandler<ApprovePrescriptionCommand, Result>
{
    public async Task<Result> Handle(
        ApprovePrescriptionCommand command, CancellationToken ct)
    {
        using var activity = ClinicalTelemetry.Source.StartActivity("ApprovePrescription");
        activity?.SetTag("prescription.id",  command.PrescriptionId);
        activity?.SetTag("prescription.medication", command.MedicationName);
        activity?.SetTag("inr.value", command.InrValue);

        var prescription = await _repository.GetByIdAsync(
            PrescriptionId.Of(command.PrescriptionId), ct);

        if (prescription is null)
        {
            activity?.SetStatus(ActivityStatusCode.Error, "Prescription not found");
            return Result.Failure(Error.NotFound("Prescription", command.PrescriptionId));
        }

        var result = prescription.Approve(command.InrValue, command.CheckedAt, command.ApprovedBy);

        if (result.IsFailure)
            activity?.SetStatus(ActivityStatusCode.Error, result.Error.Message);
        else
            activity?.SetTag("prescription.status", "Approved");

        return result;
    }
}

// Register the ActivitySource with OpenTelemetry:
.WithTracing(tracing => tracing
    .AddSource("SystemForge.Clinical.Prescriptions")
    // ...other sources
)

Custom Metrics

C#
// Clinical business metrics visible in Aspire Dashboard and production monitoring

public static class ClinicalMetrics
{
    private static readonly Meter Meter =
        new Meter("SystemForge.Clinical.Prescriptions");

    public static readonly Counter<long> PrescriptionsApproved =
        Meter.CreateCounter<long>(
            "clinical.prescriptions.approved.total",
            description: "Total Warfarin and other prescriptions approved");

    public static readonly Histogram<double> InrValueAtApproval =
        Meter.CreateHistogram<double>(
            "clinical.inr.value_at_approval",
            unit: "INR",
            description: "Distribution of INR values at prescription approval");

    public static readonly ObservableGauge<long> PendingPrescriptions =
        Meter.CreateObservableGauge<long>(
            "clinical.prescriptions.pending.count",
            () => PrescriptionCountGauge.GetPendingCount(),
            description: "Current count of prescriptions awaiting approval");
}

// In the handler, after successful approval:
ClinicalMetrics.PrescriptionsApproved.Add(1, new TagList
{
    ["medication"] = prescription.MedicationName.Value,
    ["ward_id"]    = prescription.WardId?.ToString() ?? "unassigned"
});

ClinicalMetrics.InrValueAtApproval.Record(command.InrValue, new TagList
{
    ["medication"] = prescription.MedicationName.Value,
    ["in_range"]   = (command.InrValue >= 2.0 && command.InrValue <= 3.0).ToString()
});

// Register the meter:
.WithMetrics(metrics => metrics.AddMeter("SystemForge.Clinical.Prescriptions"))

Exporting to Production Backends

C#
// For production: export to Azure Monitor, Jaeger, or Grafana Tempo
// Aspire Dashboard is for local development only

// Export telemetry based on environment:
private static IHostApplicationBuilder AddOpenTelemetryExporters(
    this IHostApplicationBuilder builder)
{
    var useOtlpExporter = !string.IsNullOrWhiteSpace(
        builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);

    if (useOtlpExporter)
    {
        // In production: set OTEL_EXPORTER_OTLP_ENDPOINT to your backend
        // Azure Monitor: https://ingest.monitor.azure.com/...
        // Jaeger: http://jaeger:4317
        builder.Services.AddOpenTelemetry().UseOtlpExporter();
    }

    // Azure Monitor Application Insights (alternative):
    if (!string.IsNullOrEmpty(builder.Configuration["ApplicationInsights:ConnectionString"]))
    {
        builder.Services.AddOpenTelemetry()
            .UseAzureMonitor(options =>
                options.ConnectionString =
                    builder.Configuration["ApplicationInsights:ConnectionString"]);
    }

    return builder;
}

// local.settings / appsettings.Development.json for Aspire Dashboard:
// "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:18889"
// (Aspire Dashboard's OTLP endpoint — automatically configured by AppHost)

Production issue I've seen: A team was running 4 microservices locally and debugging a slow prescription approval. They spent 40 minutes adding Console.WriteLine statements across 3 services trying to trace a request. The response was eventually found to be slow because a call to the FHIR patient service was timing out on one specific MRN format. With Aspire Dashboard, the trace would have shown: "ApprovePrescription span (3,200ms) → FhirPatientLookup span (2,900ms → timed out)" in about 10 seconds of investigation. Setting up Aspire for their service reduced their debugging time from hours to seconds for inter-service issues.


Key Takeaway

Aspire's AddServiceDefaults auto-instruments all services with OpenTelemetry — no per-service configuration needed. The Aspire Dashboard aggregates traces, structured logs, and metrics from all services in one view during local development. Add custom ActivitySource spans for clinical domain operations and Meter metrics for business events. Export to your production backend (Azure Monitor, Jaeger, Grafana Tempo) by setting OTEL_EXPORTER_OTLP_ENDPOINT — the same code works locally and in production.