.NET Aspire Dashboard
The standalone Aspire Dashboard (MIT) is one container that takes OTLP for all three signals and shows them within seconds: no account, no database to run, a GenAI visualizer for chat-completion spans, and since 13.6 a read API (/api/telemetry/*) that answers OTLP JSON. It is the quickest way to look at a Hermes turn, and the plugin's dashboard adapter reads traces and logs back from it.
Signals: traces + metrics + logs. Deployment: local (single container) · any container host. Cost: open source, no account.
One real hermes turn through type: aspire into the bundled compose stack (13.6.1, 2026-10-11) stored one trace of 7 spans and 28 log records, the resource reported hasMetrics: true, and the dashboard-verify harness read traces and logs back through the adapter. See Verified behavior.
Quick start
docker compose -f docker-compose/aspire/docker-compose.yaml up -d
Then declare the backend:
backends:
- type: aspire
endpoint: http://localhost:4418 # OTLP/HTTP (18890 in the container)
query_port: 18888 # dashboard read path: the UI port
capture_logs: true
The startup banner reads ✓ Aspire Dashboard at http://localhost:4418/v1/traces. Open http://localhost:18888: Traces, Structured logs and Metrics, and on an api.* span the GenAI visualizer shows the prompt and the response the plugin captured.
For a dashboard elsewhere, point endpoint at its OTLP/HTTP port (18890 by default) and query_port at its UI port.
Why a dedicated type: aspire
Export also works through the generic OTLP type. Declaring type: aspire instead asks the plugin to:
- Complete
endpointto the traces URL (a base URL gets/v1/traces) and derive the metrics and logs URLs from it. - Send an OTLP API key as
x-otlp-api-keywhenapi_key/api_key_env(orOTEL_ASPIRE_API_KEY) resolves, which is whatDASHBOARD__OTLP__AUTHMODE=ApiKeywants; nothing in anonymous mode. - Enable all three signals by default; the dashboard stores Gauge, Sum and Histogram points and rejects exponential histograms, so the plugin's default explicit-bucket histograms are the right shape.
- Enable the dashboard adapter for the entry.
Configuration reference
| Field | Meaning |
|---|---|
endpoint | Required. The dashboard's OTLP/HTTP base, e.g. http://localhost:4418 (bundled stack) or http://<host>:18890. Also via OTEL_ASPIRE_ENDPOINT. |
api_key / api_key_env | Optional OTLP API key → x-otlp-api-key (fallback OTEL_ASPIRE_API_KEY). Only with DASHBOARD__OTLP__AUTHMODE=ApiKey. |
metrics / logs / traces | Per-signal toggles; all three default on. |
headers | Extra headers, merged on top of the generated ones. |
query_port | Dashboard read path: the UI port (default 18888). |
ui_url | Dashboard read path: the UI origin for the "open in" link (default: the query URL). |
search_fetch, logs_fetch | Dashboard read path: traces and log records fetched per query before the window and filters are applied (defaults 500 and 5000). |
service_name | Dashboard read path: the service whose resources (one per Hermes process) the API is asked for (default: the plugin's resource_attributes.service.name, else hermes-agent). |
The adapter's own api_key goes in x-api-key when DASHBOARD__API__AUTHMODE=ApiKey; the same field serves both because the two keys are usually the same deployment-level secret.
Single backend (env vars)
export OTEL_ASPIRE_ENDPOINT=http://localhost:4418
export OTEL_PROJECT_NAME=hermes-otel-aspire
What you'll see
The Traces page lists the agent turn with its llm.*, api.* and tool.* children; the span details show every attribute in both conventions, and the GenAI visualizer renders gen_ai.input.messages / gen_ai.output.messages as a conversation. Structured logs carry the trace and span of the lines written inside a span and link to them. Metrics has the plugin's counters and histograms per resource.
Dashboard adapter
The Hermes dashboard's OTel tab reads the dashboard's API on query_port:
- Traces:
GET /api/telemetry/traces?resource=<instance>&…&limit=<search_fetch>returns OTLP JSON for every matching trace.- An Aspire resource is one
service.name+service.instance.id, that is, one Hermes process. The bare service name stops matching once a second process has reported, so the adapter first lists/api/telemetry/resourcesand names every instance of the service (theresourceparameter is repeatable). - The API has no time window and no attribute, status or name filters: on 13.6.1 it honours only
resource,limitandoffset. The adapter therefore applies the window, every structured filter (status = okand free text included), roots-only and the paging cursor to the rows it fetched. - The detail is
GET /api/telemetry/traces/{traceId}, already in the OTLP shape the frontend reads. Each trace links to the Aspire UI's own/traces/detail/{traceId}page.
- An Aspire resource is one
- Logs:
GET /api/telemetry/logswith the same instance list,limit=<logs_fetch>, plustraceIdandsearchwhen the filter sets them; span, logger, severity, session and event filters are applied to the rows. Severities arrive asInformation/Warning/Criticaland are mapped fromseverityNumber. - Metrics: no read route exists, so the source reports
metrics: false; the Aspire UI shows them.
Verified behavior
| Check | Result |
|---|---|
| Ingest | /v1/traces, /v1/metrics, /v1/logs → 200 (protobuf and JSON) |
| Spans | 7 in one trace (agent, llm.nvidia/…, three api.nvidia/…, tool.terminal, tool.read_file); /api/telemetry/traces returns them as resourceSpans with totalCount / returnedCount |
| Logs | 28 records, 12 with traceId and spanId; ?traceId= → 12, ?search=API call → 4, ?limit=2&offset=2 pages oldest-first |
| Resources | /api/telemetry/resources → hermes-agent with hasLogs, hasTraces, hasMetrics all true |
| Read API filters | resource (repeatable; a bare service name is ambiguous once two processes have reported), limit, offset, traceId, search honoured; ?resource=<unknown> → 404; no time, severity, scope or attribute parameters recognised |
| UI routes | /traces/detail/{traceId} → 200; an unknown route → 404 |
| Dashboard verify harness | fresh four-turn batch: PASS 48 · SKIP 3 (the skips are the metrics checks the read API cannot serve); per-trace log lines and every filter match the live store |
| Resident memory | about 75 MB |
Caveats
- Persistence: the default
ASPIRE_DASHBOARD_PERSISTENCE_MODE=Nonedeletes the SQLite database on a clean shutdown; the bundled stack setsResumewith a volume on theappuser's home (a volume on a fresh path is root-owned and the dashboard cannot write it). - Exponential histograms are rejected; keep
metrics_histogram: explicit. - The read API has no metrics route and no server-side filters beyond
resource/limit/offset/traceId/search, so large stores make the adapter fetch more than it shows (search_fetch,logs_fetch). ASPIRE_DASHBOARD_*is the documented variable prefix;DOTNET_DASHBOARD_*still works as an undocumented fallback.- Limits: 100 000 traces and logs, 50 000 metric points per dimension, 128 attributes per item (
DASHBOARD__TELEMETRYLIMITS__*); upstream calls the dashboard a development tool, not a durable telemetry backend.
Troubleshooting
aspire requires endpoint— noendpoint:and noOTEL_ASPIRE_ENDPOINT.- Container restarts with
Access to the path … is denied— the data directory is not writable by the image'sappuser; mount the volume on/home/appas the bundled file does. - Dashboard shows nothing after a restart — persistence mode
None; setResumewith a data directory. - Metrics tab says not served — expected: the read API has no metrics route; use the Aspire UI.