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.
docker 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 runThe 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
| Page | What you do there |
|---|---|
| Structured logs | Filter by resource, level and any property (OrderId = 10482); open an entry to see all properties and jump to its trace |
| Traces | Waterfall of one trace across services, span tags, events and recorded exceptions; errors highlighted |
| Metrics | Charts 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
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.
# 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.
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.
{
"$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