The Aspire Dashboard

Run the Aspire dashboard as a local OTLP endpoint and read an API's logs, traces and metrics together, linked by trace id.

Syntaxdocker run --rm -p 18888:18888 -p 4317:18889 mcr.microsoft.com/dotnet/aspire-dashboard:latest OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 OTEL_SERVICE_NAME=orders-api dotnet run

The Aspire dashboard is a web UI that is also an OTLP receiver. Point an app's OpenTelemetry exporter at it and you get structured logs, traces and metrics for every service in one place, linked by trace id, without running Jaeger, Prometheus and Loki on a laptop. It starts automatically inside an Aspire AppHost, and it also runs standalone as the container image mcr.microsoft.com/dotnet/aspire-dashboard, which works for any OTLP-emitting app in any language.

Running it standalone

Inside the container the UI listens on 18888, OTLP over gRPC on 18889 and OTLP over HTTP on 18890. Map them to the conventional ports: -p 18888:18888 -p 4317:18889 -p 4318:18890. The UI is protected by a browser token printed in the container log at start-up as a login URL. The OTLP endpoint accepts any sender unless you set Dashboard__Otlp__AuthMode=ApiKey and Dashboard__Otlp__PrimaryApiKey, after which exporters must send an x-otlp-api-key header (OTEL_EXPORTER_OTLP_HEADERS=x-otlp-api-key=...).

Pointing the app at it

Configure OpenTelemetry for all three signals and leave the destination to environment variables: OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 and OTEL_SERVICE_NAME=orders-api in launchSettings.json or compose. UseOtlpExporter() on the OpenTelemetry builder configures one exporter for logs, metrics and traces together. For logs, set IncludeScopes and IncludeFormattedMessage to true, or scope properties and the rendered message are missing from the structured log view. Metrics export every 60 seconds by default, so set OTEL_METRIC_EXPORT_INTERVAL=5000 in development or the charts look frozen. Under an AppHost all of this is injected for you, which is what Aspire's ServiceDefaults project wires.

Reading the signals together

PageWhat you do there
Structured logsFilter by resource, level and any property (OrderId = 10482); open an entry to see all properties and jump to its trace
TracesWaterfall of one trace across services, span tags, events and recorded exceptions; errors highlighted
MetricsCharts per instrument with tag filters; histogram percentiles for request duration
Resources (only under an AppHost)Each resource with its state, environment variables, endpoints and console output; not shown when the dashboard runs standalone

A typical session: a test call returns 500. Filter structured logs to Error, open the entry, follow its trace link, and the waterfall shows the payments span failing after 5 seconds with the exception recorded on it; the metrics page shows http.client.request.duration rising for the same host since the last deploy.

What it is not

The dashboard keeps telemetry in memory with a cap on the number of logs, traces and metric points (settings under Dashboard:TelemetryLimits) and loses everything on restart. It has no alerting, no retention and no access control beyond the token or an OpenID Connect login. That makes it right for development, debugging a CI run or a single test environment, and wrong as a production backend: there, the same OTLP export goes to a Collector and a durable backend, and the application code does not change, only the endpoint. Leaving an unauthenticated dashboard reachable on a shared network exposes every log line, including any personal data in them.

Example

Example · csharp
using System.Diagnostics;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// One OTLP exporter for all three signals; the endpoint comes from OTEL_EXPORTER_OTLP_ENDPOINT.
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService(builder.Configuration["OTEL_SERVICE_NAME"] ?? "orders-api"))
    .WithLogging(configureBuilder: null, configureOptions: options =>
    {
        options.IncludeScopes = true;           // TraceId scope, BeginScope properties
        options.IncludeFormattedMessage = true; // rendered text next to the template
    })
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSource(Payments.SourceName))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddMeter("System.Runtime"))
    .UseOtlpExporter();

builder.Services.AddHttpClient<Payments>(c =>
    c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"] ?? "http://localhost:5199"));

var app = builder.Build();

app.MapPost("/orders/{orderId:int}/pay", async (int orderId, decimal amount, Payments payments,
    ILogger<Payments> logger, CancellationToken ct) =>
{
    using (logger.BeginScope(new Dictionary<string, object?> { ["OrderId"] = orderId }))
    {
        try
        {
            await payments.AuthoriseAsync(orderId, amount, ct);
            logger.LogInformation("Payment of {Amount} authorised", amount);
            return Results.NoContent();
        }
        catch (HttpRequestException ex)
        {
            logger.LogError(ex, "Payment of {Amount} failed", amount);
            return Results.Problem(statusCode: StatusCodes.Status502BadGateway, title: "Payment provider unavailable");
        }
    }
});

app.Run();

public sealed class Payments(HttpClient http)
{
    public const string SourceName = "Shop.Payments";
    private static readonly ActivitySource Source = new(SourceName);

    public async Task AuthoriseAsync(int orderId, decimal amount, CancellationToken ct)
    {
        using var activity = Source.StartActivity("payment.authorise");
        activity?.SetTag("order.id", orderId);
        using var response = await http.PostAsJsonAsync("/authorisations", new { orderId, amount }, ct);
        response.EnsureSuccessStatusCode();
    }
}

When to use it

  • Debug a 500 from POST /orders/10482/pay locally by jumping from the error log line to the trace showing the payment provider timing out.
  • Watch http.server.request.duration percentiles while load-testing a branch on a laptop, with no Prometheus or Grafana installed.
  • Give a team's shared test environment one place to see logs and traces from five services, protected by an OTLP API key.

More examples

Starting the standalone dashboard

Mapping container port 18889 to host 4317 means the app can use the OTLP default port and the same configuration works later against a real Collector. The token in the log protects the UI; anyone who can reach port 4317 can still send telemetry unless OTLP API key auth is enabled. Pin a specific image tag instead of <code>latest</code> in any shared setup so a dashboard upgrade does not surprise the team.

Example · bash
# UI on 18888; OTLP gRPC on host 4317 and OTLP HTTP on host 4318.
docker run --rm -d --name aspire-dashboard \
  -p 18888:18888 -p 4317:18889 -p 4318:18890 \
  mcr.microsoft.com/dotnet/aspire-dashboard:latest

# The login URL with its one-time token is in the log.
docker logs aspire-dashboard 2>&1 | grep -i "login"

# Run the API against it with a short metric interval for development.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_SERVICE_NAME=orders-api \
OTEL_METRIC_EXPORT_INTERVAL=5000 \
dotnet run --project src/Orders.Api

# Clean up when done.
docker rm -f aspire-dashboard

Docker Compose: API plus dashboard

Inside the compose network the API talks to the dashboard's container port 18889 directly, so only the UI port is published to the host. The API key comes from the shell or a <code>.env</code> file that is not committed; compose refuses to start without it because of the <code>:?</code> syntax. With API key auth on, a misconfigured service that omits the header gets rejected instead of silently mixing into the team's view.

Example · yaml
services:
  dashboard:
    image: mcr.microsoft.com/dotnet/aspire-dashboard:latest
    ports:
      - "18888:18888"
    environment:
      Dashboard__Otlp__AuthMode: ApiKey
      Dashboard__Otlp__PrimaryApiKey: ${OTLP_API_KEY:?set OTLP_API_KEY}

  orders-api:
    build: ./src/Orders.Api
    ports:
      - "8080:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      OTEL_SERVICE_NAME: orders-api
      OTEL_EXPORTER_OTLP_ENDPOINT: http://dashboard:18889
      OTEL_EXPORTER_OTLP_PROTOCOL: grpc
      OTEL_EXPORTER_OTLP_HEADERS: x-otlp-api-key=${OTLP_API_KEY}
      OTEL_METRIC_EXPORT_INTERVAL: "5000"
    depends_on:
      - dashboard

launchSettings.json for local runs

launchSettings.json applies only to <code>dotnet run</code> and IDE launches, never to a published container, so development-only telemetry settings belong here rather than in appsettings.json. Every developer gets the dashboard wiring by running the standalone container once. When the solution moves to an Aspire AppHost these variables are injected automatically and this profile can be dropped.

Example · json
{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "launchBrowser": false,
      "applicationUrl": "http://localhost:5180",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "OTEL_SERVICE_NAME": "orders-api",
        "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
        "OTEL_METRIC_EXPORT_INTERVAL": "5000",
        // Overrides a Warning level in appsettings.json so structured logs show EF Core SQL text; development only.
        // Statement text on spans comes from the database instrumentation, not from log levels.
        "Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command": "Information"
      }
    }
  }
}

Discussion

  • Be the first to comment on this lesson.