End-to-End Observability in .NET with Aspire, OpenTelemetry, and the LGTM Stack

Mehdi Hadeli
@mehdihadeli
On this page
Table of contents
Introduction
Observability is not a feature you bolt on after launch — it is a first-class concern that shapes how you debug, monitor, and operate your system. In the .NET ecosystem, the combination of .NET Aspire, OpenTelemetry (OTel), and the OpenTelemetry Collector gives you a vendor-neutral pipeline that exports telemetry to any backend you choose.
In this article, we take the AspireShop sample — a microservices e-commerce app with a catalog service, basket service, and a Blazor frontend — and instrument it end-to-end. We stand up a full observability stack using Docker Compose, configure the OTel Collector to fan out traces, metrics, and logs to Prometheus, Tempo, Loki, and Grafana (the LGTM stack). As a bonus, we keep an ELK stack (Elasticsearch + Kibana) running alongside as an optional alternative, and we discuss when you would pick one over the other.
By the end you will understand:
- How .NET Aspire projects emit OTel telemetry out of the box.
- How to configure the OTel Collector to receive, process, and export signals.
- How to provision Prometheus, Tempo, Loki, and Grafana with Docker Compose.
- How to explore traces, metrics, and logs in Grafana.
- Where the ELK stack fits and why you might choose it instead.
Architecture Overview
The telemetry pipeline follows a clean fan-out pattern: every .NET service pushes OTLP to the Collector, and the Collector routes each signal type to the appropriate backend.
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#4A90D9', 'primaryTextColor': '#1a1818', 'primaryBorderColor': '#2E6BA9',
'secondaryColor': '#7B68EE', 'secondaryTextColor': '#141313', 'secondaryBorderColor': '#5A4FCF',
'tertiaryColor': '#50C878', 'tertiaryTextColor': '#221e1e', 'tertiaryBorderColor': '#2E8B57',
'lineColor': '#888', 'textColor': '#333', 'titleColor': '#333', 'fontSize': '14px'
}}}%%
graph TB
subgraph ".NET Aspire Host"
FW[Frontend - Blazor]
CS[CatalogService]
BS[BasketService]
end
subgraph "OpenTelemetry Collector"
OTLP_R[OTLP Receiver<br/>gRPC :4317 / HTTP :4318]
PROC[Batch + Transform Processors]
end
subgraph "LGTM Stack"
PROM[Prometheus<br/>Metrics + OTLP Receiver]
TEMPO[Tempo<br/>Traces]
LOKI[Loki<br/>Logs]
GRAF[Grafana<br/>Dashboards]
MINIO[MinIO<br/>S3 Object Storage]
end
subgraph "Optional: ELK Stack"
ES[Elasticsearch]
KIB[Kibana]
end
FW -->|OTLP| OTLP_R
CS -->|OTLP| OTLP_R
BS -->|OTLP| OTLP_R
OTLP_R --> PROC
PROC -->|metrics| PROM
PROC -->|traces| TEMPO
PROC -->|logs| LOKI
PROC -->|traces + logs| ES
TEMPO -->|blocks| MINIO
LOKI -->|chunks + index| MINIO
GRAF --> PROM
GRAF --> TEMPO
GRAF --> LOKI
GRAF --> ES
KIB --> ES
style FW fill:#4A90D9,stroke:#2E6BA9,color:#fff
style CS fill:#4A90D9,stroke:#2E6BA9,color:#fff
style BS fill:#4A90D9,stroke:#2E6BA9,color:#fff
style OTLP_R fill:#7B68EE,stroke:#5A4FCF,color:#fff
style PROC fill:#7B68EE,stroke:#5A4FCF,color:#fff
style PROM fill:#E67E22,stroke:#C0651F,color:#fff
style TEMPO fill:#50C878,stroke:#2E8B57,color:#fff
style LOKI fill:#1ABC9C,stroke:#148F77,color:#fff
style GRAF fill:#F1C40F,stroke:#D4AC0D,color:#333
style MINIO fill:#95A5A6,stroke:#717D7E,color:#fff
style ES fill:#00BCD4,stroke:#0097A7,color:#fff
style KIB fill:#E91E90,stroke:#C2185B,color:#fff
Every signal type is handled by the backend designed for it. Traces go to Tempo, logs to Loki, metrics to Prometheus — and all three are queried through a single pane of glass in Grafana. Tempo and Loki store their data in MinIO, an S3-compatible object store, so trace blocks and log chunks survive container restarts. The Elasticsearch exporter on the Collector also pushes traces and logs into Elasticsearch, where Kibana provides an alternative full-featured UI.
Prerequisites
- .NET 9 SDK (or later)
- Docker Desktop
- The AspireShop sample cloned locally
Part 1: Instrumenting the .NET Application
The Service Defaults Library
Aspire projects share instrumentation through a Service Defaults project. In the AspireShop solution, AspireShop.ServiceDefaults defines the AddServiceDefaults() extension that every service calls in its Program.cs:
// AspireShop.CatalogService/Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // <-- wires up OTel, health checks, service discovery
builder.AddNpgsqlDbContext<CatalogDbContext>("catalogdb");
// ...
The AddServiceDefaults method does three things: it configures OpenTelemetry, adds health checks, and enables service discovery with resilience. The OpenTelemetry setup lives in ConfigureOpenTelemetry:
// AspireShop.ServiceDefaults/Extensions.cs (excerpt)
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
.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
tracing.Filter = httpContext =>
!(httpContext.Request.Path.StartsWithSegments("/health")
|| httpContext.Request.Path.StartsWithSegments("/alive")))
.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
The instrumentation covers the key signals:
- Metrics: ASP.NET Core request duration, HTTP client calls, and .NET runtime metrics (GC, heap, thread pool).
- Tracing: Inbound HTTP requests, outbound HTTP calls, and gRPC client calls. Health-check endpoints are filtered out to reduce noise.
- Logging:
ILoggerentries are bridged into the OTel pipeline, preserving formatted messages and scopes.
Exporting to the Collector
The OTLP exporter is wired in a separate private method. It checks for the OTEL_EXPORTER_OTLP_ENDPOINT environment variable — if set, it calls UseOtlpExporter():
// AspireShop.ServiceDefaults/Extensions.cs (excerpt)
private static IHostApplicationBuilder AddOpenTelemetryExporters(
this IHostApplicationBuilder builder)
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(
builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
The UseOtlpExporter() call reads the standard OTel environment variables — OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL — so you never hardcode a collector address.
Injecting the Endpoint from the AppHost
The .NET Aspire AppHost is the orchestration layer. It reads the standard OTEL_EXPORTER_OTLP_ENDPOINT environment variable (with a localhost fallback) and injects it into every service project:
// AspireShop.AppHost/AppHost.cs (excerpt)
var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4317";
var otlpProtocol = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_PROTOCOL") ?? "grpc";
var catalogService = builder
.AddProject<Projects.AspireShop_CatalogService>("catalogservice")
.WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", otlpEndpoint)
.WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", otlpProtocol)
.WithReference(catalogDb)
.WaitFor(catalogDbManager);
// Same pattern for basketService and frontend...
No custom configuration keys — just the standard OTel environment variables. The AppHost reads them, and each service receives them through .WithEnvironment().
Fallback for Standalone Runs
When you run a service outside the AppHost (for example, during development with dotnet run), the OTLP variables come from launchSettings.json:
// AspireShop.CatalogService/Properties/launchSettings.json (excerpt)
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"
}
The ASPIRE_ALLOW_UNSECURED_TRANSPORT flag is important: in local development the Collector runs without TLS, and Aspire needs explicit permission to skip it.
Running Services Standalone
There are two ways to start the services. When you run through the AppHost (dotnet run --project AspireShop.AppHost), the host injects OTEL_EXPORTER_OTLP_ENDPOINT into every project. But you can also run individual services directly — for example, when debugging the BasketService in isolation:
dotnet run --project AspireShop.BasketService --launch-profile https
This works because every service's launchSettings.json has the same OTEL_EXPORTER_OTLP_ENDPOINT variable. Even without the AppHost, the service resolves the endpoint from launchSettings.json and starts pushing telemetry to the Collector on localhost:4317. The same is true for CatalogService, CatalogDbManager, and Frontend — all four projects share the identical OTLP configuration in their launch profiles.
This dual-path design means you get consistent observability whether you run the full distributed app via the AppHost or spin up a single service for focused debugging.
The Aspire Dashboard: A Second Pane of Glass
While this article focuses on the external LGTM stack, the .NET Aspire Dashboard is also receiving telemetry. Look at the Collector's otlp/aspire exporter in the pipeline config:
otlp/aspire:
endpoint: '${env:ASPIRE_OTLP_ENDPOINT}'
headers:
x-otlp-api-key: '${env:ASPIRE_API_KEY}'
tls:
insecure: ${env:ASPIRE_INSECURE}
insecure_skip_verify: true
This exporter fans out all three signals (traces, metrics, and logs) to the Aspire Dashboard — a built-in developer UI that starts alongside the AppHost. The Collector acts as a telemetry fork: one copy goes to the LGTM stack (Prometheus/Tempo/Loki), another copy goes to the Aspire Dashboard for quick local inspection during development.
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#4A90D9', 'primaryTextColor': '#181717', 'primaryBorderColor': '#2E6BA9',
'secondaryColor': '#7B68EE', 'secondaryTextColor': '#0e0d0d', 'secondaryBorderColor': '#5A4FCF',
'tertiaryColor': '#50C878', 'tertiaryTextColor': '#0e0d0d', 'tertiaryBorderColor': '#2E8B57',
'lineColor': '#888', 'textColor': '#333', 'titleColor': '#333', 'fontSize': '14px'
}}}%%
graph LR
APP[.NET Services] -->|OTLP| COL[OTel Collector]
COL -->|traces + metrics + logs| LGTM[LGTM Stack<br/>Grafana]
COL -->|traces + metrics + logs| ASP[Aspire Dashboard<br/>Dev UI]
style APP fill:#4A90D9,stroke:#2E6BA9,color:#fff
style COL fill:#7B68EE,stroke:#5A4FCF,color:#fff
style LGTM fill:#F1C40F,stroke:#D4AC0D,color:#333
style ASP fill:#6C5CE7,stroke:#4834D4,color:#fff
The Aspire Dashboard gives you structured logs, trace waterfalls, and metrics charts without opening Grafana — useful during active development. The external Grafana stack is for deeper analysis, historical queries, alerting, and team-wide dashboards. Both consume the same telemetry; you choose which pane of glass fits the moment.
Two Paths to the Dashboard: Direct vs Collector-Routed
There are two ways to get telemetry into the Aspire Dashboard, and understanding the difference matters.
Path A: Direct AppHost → Dashboard (Default Aspire Pattern)
The AppHost's own launchSettings.json configures how the AppHost communicates with the dashboard it spawns. The official Aspire sample uses these variables:
// AspireShop.AppHost/Properties/launchSettings.json
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16224",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22123"
}
| Variable | Default | Purpose |
|---|---|---|
ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL | http://localhost:18889 | OTLP/gRPC endpoint where the dashboard listens for telemetry |
ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL | null | gRPC endpoint the dashboard connects to for the resource list and console logs |
In the default Aspire pattern, the AppHost injects OTEL_EXPORTER_OTLP_ENDPOINT into child services pointing to this same dashboard OTLP endpoint. The result: every service sends telemetry directly to the Aspire Dashboard — no Collector in the path.
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#4A90D9', 'primaryTextColor': '#1b1919', 'primaryBorderColor': '#2E6BA9',
'secondaryColor': '#6C5CE7', 'secondaryTextColor': '#1d1c1c', 'secondaryBorderColor': '#4834D4',
'lineColor': '#888', 'textColor': '#333', 'titleColor': '#333', 'fontSize': '14px'
}}}%%
graph LR
subgraph "Default Aspire Pattern"
APP[AppHost] -->|spawns| DASH[Aspire Dashboard<br/>OTLP :18889]
SVC1[Service A] -->|OTLP| DASH
SVC2[Service B] -->|OTLP| DASH
end
style APP fill:#F1C40F,stroke:#D4AC0D,color:#333
style DASH fill:#6C5CE7,stroke:#4834D4,color:#fff
style SVC1 fill:#4A90D9,stroke:#2E6BA9,color:#fff
style SVC2 fill:#4A90D9,stroke:#2E6BA9,color:#fff
This is perfect for local development when all you need is the built-in dashboard. Zero infrastructure beyond the AppHost itself.
Path B: Collector-Routed (Our Setup)
In this article we reroute telemetry through the OTel Collector. Our AppHost's launchSettings.json sets both variables side by side:
// AspireShop.AppHost/Properties/launchSettings.json
"environmentVariables": {
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16224",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22123",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"
}
ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL tells the AppHost where the dashboard listens. OTEL_EXPORTER_OTLP_ENDPOINT, set to localhost:4317, tells the AppHost (and every child service) to send telemetry to the Collector instead of directly to the dashboard. The Collector then fans out to both the dashboard and the LGTM stack:
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#4A90D9', 'primaryTextColor': '#111010', 'primaryBorderColor': '#2E6BA9',
'secondaryColor': '#7B68EE', 'secondaryTextColor': '#181616', 'secondaryBorderColor': '#5A4FCF',
'tertiaryColor': '#50C878', 'tertiaryTextColor': '#1b1a1a', 'tertiaryBorderColor': '#2E8B57',
'lineColor': '#888', 'textColor': '#333', 'titleColor': '#333', 'fontSize': '14px'
}}}%%
graph LR
subgraph "Collector-Routed Pattern"
SVC1[Service A] -->|OTLP| COL[OTel Collector<br/>:4317]
SVC2[Service B] -->|OTLP| COL
COL -->|otlp/aspire| DASH2[Aspire Dashboard]
COL -->|otlphttp/prometheus| PROM[Prometheus]
COL -->|otlp/tempo| TEMPO[Tempo]
COL -->|otlphttp/loki| LOKI[Loki]
end
style SVC1 fill:#4A90D9,stroke:#2E6BA9,color:#fff
style SVC2 fill:#4A90D9,stroke:#2E6BA9,color:#fff
style COL fill:#7B68EE,stroke:#5A4FCF,color:#fff
style DASH2 fill:#6C5CE7,stroke:#4834D4,color:#fff
style PROM fill:#E67E22,stroke:#C0651F,color:#fff
style TEMPO fill:#50C878,stroke:#2E8B57,color:#fff
style LOKI fill:#1ABC9C,stroke:#148F77,color:#fff
The AppHost's launchSettings.json still has ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL — that configures where the dashboard listens. But the AppHost code overrides OTEL_EXPORTER_OTLP_ENDPOINT to point to the Collector, not the dashboard.
When to Use Each
| Scenario | Use |
|---|---|
| Local dev, only need the built-in dashboard | Path A — default Aspire. No extra infrastructure. |
| Need external backends (Grafana, Prometheus, Tempo) | Path B — route through Collector. One telemetry stream, multiple destinations. |
| Production or staging with centralized observability | Path B — Collector gives you batching, filtering, and multi-backend export. |
| Debugging a single service in isolation | Either — launchSettings fallback works for both paths. |
The two paths are not mutually exclusive. The AppHost always sends its own infrastructure telemetry (resource lifecycle events, startup logs) to the dashboard via ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL. Path B only changes where service-level telemetry goes first — it still ends up at the dashboard, just through the Collector.
No separate dashboard container. The Aspire Dashboard is not defined in
docker-compose.infrastructure.yaml. The AppHost launches it as a child process automatically when you rundotnet run. The Collector (running in Docker) reaches it athost.docker.internal:16224via theASPIRE_OTLP_ENDPOINTenv var. There is nothing extra to containerize — it just works.
At this point the .NET side is done. Every service emits OTLP over gRPC to localhost:4317, and the Collector fans it to both the Aspire Dashboard and the external observability stack. Next we build the infrastructure that receives it.
Part 2: The Docker Compose Infrastructure Stack
The full observability stack is defined in a single Docker Compose file: deployments/docker-compose/docker-compose.infrastructure.yaml. Here is the service roster:
| Service | Image | Port(s) | Role |
|---|---|---|---|
postgres | postgres:16.4-alpine | 5432 | Catalog database |
basketcache | redis/redis-stack:7.4.0-v0 | 6379, 8001 | Basket cache |
minio | minio/minio:RELEASE.2025-09-07T16-13-09Z | 9000, 9001 | S3 storage for Loki & Tempo |
minio-bucket-creator | minio/mc:latest | — | Creates loki & tempo buckets |
otel-collector | otel/opentelemetry-collector-contrib:0.110.0 | 4317, 4318, 8888 | Telemetry pipeline |
prometheus | prom/prometheus:v3.3.1 | 9090 | Metrics store |
tempo | grafana/tempo:2.6.0 | 3200, 4317* | Trace store |
loki | grafana/loki:3.2.0 | 3100 | Log store |
grafana | grafana/grafana:11.2.3 | 3000 | Dashboards |
node-exporter | prom/node-exporter:v1.8.2 | 9100 | Host metrics |
elasticsearch | elasticsearch:8.15.2 | 9200, 9300 | Optional log/trace store |
kibana | kibana:8.15.2 | 5601 | Optional ELK UI |
Tempo's internal OTLP port (4317) is used only by the Collector — it does not conflict with the Collector's own 4317 because they are on separate containers on the same Docker network.
OpenTelemetry Collector Configuration
The Collector is the heart of the pipeline. Its configuration (otel-collector-config.yaml) defines receivers, processors, and exporters:
# deployments/configs/otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
send_batch_size: 1024
transform/environment:
error_mode: ignore
trace_statements:
- context: resource
statements:
- set(attributes["environment"], "development")
log_statements:
- context: resource
statements:
- set(attributes["environment"], "development")
metric_statements:
- context: resource
statements:
- set(attributes["environment"], "development")
exporters:
otlphttp/prometheus:
endpoint: '${env:PROMETHEUS_ENDPOINT}'
tls:
insecure: true
otlphttp/loki:
endpoint: '${env:LOKI_ENDPOINT}'
tls:
insecure: true
otlp/tempo:
endpoint: '${env:TEMPO_URL}'
tls:
insecure: true
otlp/aspire:
endpoint: '${env:ASPIRE_OTLP_ENDPOINT}'
headers:
x-otlp-api-key: '${env:ASPIRE_API_KEY}'
tls:
insecure: ${env:ASPIRE_INSECURE}
insecure_skip_verify: true
elasticsearch:
endpoints:
- 'http://elasticsearch:9200'
mapping:
mode: otel
sending_queue:
enabled: true
retry:
enabled: true
debug:
verbosity: basic
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, transform/environment]
exporters: [otlp/tempo, otlp/aspire, elasticsearch, debug]
metrics:
receivers: [otlp]
processors: [batch, transform/environment]
exporters: [otlphttp/prometheus, otlp/aspire, debug]
logs:
receivers: [otlp]
processors: [batch, transform/environment]
exporters: [otlphttp/loki, otlp/aspire, elasticsearch, debug]
Key design decisions here:
otlphttp/prometheuspushes metrics from the Collector to Prometheus's OTLP HTTP endpoint. Prometheus v3.3.1 has a built-in OTLP receiver, enabled via--web.enable-otlp-receiverin the Compose command. Metrics arrive athttp://prometheus:9090/api/v1/otlpas OTLP, and Prometheus stores them natively — no remote-write bridge needed.otlphttp/lokisends logs as OTLP HTTP. Loki'sallow_structured_metadata: truepreserves OTel resource attributes.otlp/temposends traces over gRPC, which Tempo receives on its own OTLP endpoint.elasticsearchuses the native Elasticsearch exporter withmapping: mode: otel, which maps OTel semantic conventions to Elastic's schema.- The
transform/environmentprocessor tags all signals withenvironment=development, making it easy to filter by environment in dashboards.
How Metrics Flow: Collector → Prometheus via OTLP
This is the trickiest piece of the pipeline, so let's trace it step by step:
%%{init: {'theme': 'base', 'themeVariables': {
'actorBkg': '#4A90D9', 'actorBorder': '#2E6BA9', 'actorTextColor': '#fff',
'actorLineColor': '#4A90D9', 'signalColor': '#888', 'signalTextColor': '#333',
'labelBoxBkgColor': '#FFF3CD', 'labelBoxBorderColor': '#F1C40F', 'labelTextColor': '#333',
'noteBkgColor': '#E8F8F5', 'noteBorderColor': '#1ABC9C', 'noteTextColor': '#333',
'activationBkgColor': '#D6EAF8', 'activationBorderColor': '#4A90D9',
'sequenceNumberColor': '#fff'
}}}%%
sequenceDiagram
participant APP as .NET Service
participant COL as OTel Collector
participant PROM as Prometheus
APP->>COL: OTLP metrics (gRPC :4317)
Note over COL: otlphttp/prometheus exporter<br/>targets ${PROMETHEUS_ENDPOINT}
COL->>PROM: POST /api/v1/otlp (HTTP)
Note over PROM: --web.enable-otlp-receiver<br/>enables the OTLP HTTP endpoint
Note over PROM: otlp: promote_resource_attributes<br/>promotes service.name etc. into labels
- The .NET service emits metrics over OTLP gRPC to the Collector on port
4317. - The Collector's
otlphttp/prometheusexporter transforms those metrics back to OTLP (HTTP this time) and POSTs them tohttp://prometheus:9090/api/v1/otlp. The URL comes from thePROMETHEUS_ENDPOINTenv var set in Docker Compose. - Prometheus receives the OTLP payload through its native OTLP HTTP receiver — enabled by the
--web.enable-otlp-receiverflag. - The
otlp:block inprometheus.yamlpromotes resource attributes (service.name,service.instance.id, etc.) from OTLP metadata into Prometheus labels, so every metric is queryable by service.
Two sides must match:
- Collector side:
otlphttp/prometheusexporter withendpoint: "${env:PROMETHEUS_ENDPOINT}"→ resolved tohttp://prometheus:9090/api/v1/otlp - Prometheus side: OTLP receiver enabled via CLI flag +
otlp:config block for attribute promotion
The Collector's environment variables are injected by the Compose file:
# docker-compose.infrastructure.yaml (excerpt)
otel-collector:
environment:
- ASPIRE_OTLP_ENDPOINT=${ASPIRE_OTLP_ENDPOINT:-host.docker.internal:16224}
- ASPIRE_API_KEY=${ASPIRE_API_KEY:-}
- ASPIRE_INSECURE=true
- PROMETHEUS_ENDPOINT=http://prometheus:9090/api/v1/otlp
- LOKI_ENDPOINT=http://loki:3100/otlp
- TEMPO_URL=tempo:4317
MinIO: S3 Object Storage for Loki & Tempo
By default, Loki and Tempo use local filesystem storage — simple, but not durable. Recreate the container and you lose your logs and traces. MinIO is an S3-compatible object store that gives you persistent, scalable storage without changing a single line of application code.
The Docker Compose file defines two cooperating services: the MinIO server and a bucket creator that runs once to initialize the loki and tempo buckets:
# docker-compose.infrastructure.yaml (minio services excerpt)
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
restart: unless-stopped
command:
- server
- /data
- --console-address
- :9001
environment:
MINIO_ROOT_USER: ${MINIO_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
MINIO_PROMETHEUS_AUTH_TYPE: public
ports:
# MinIO S3 API
- '9000:9000'
# MinIO Console UI
- '9001:9001'
volumes:
- minio_data:/data
minio-bucket-creator:
image: minio/mc:latest
restart: on-failure
entrypoint:
- /bin/sh
command:
- -c
- |
/usr/bin/mc alias set myminio http://minio:9000 $${MINIO_USER} $${MINIO_PASSWORD}
/usr/bin/mc mb myminio/loki --ignore-existing
/usr/bin/mc anonymous set public myminio/loki
/usr/bin/mc mb myminio/tempo --ignore-existing
/usr/bin/mc anonymous set public myminio/tempo
exit 0
environment:
MINIO_USER: ${MINIO_USER:-minioadmin}
MINIO_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
depends_on:
minio:
condition: service_started
The minio-bucket-creator runs mc (the MinIO CLI) to create two buckets — loki for log chunks and tempo for trace blocks — then exits immediately. The --ignore-existing flag makes the command idempotent: safe to restart.
Tempo and Loki are configured to wait for this setup before starting:
# docker-compose.infrastructure.yaml (tempo + loki excerpt)
tempo:
image: grafana/tempo:2.6.0
restart: unless-stopped
command: ['-config.file=/etc/tempo.yaml', '-config.expand-env=true']
environment:
- MINIO_USER=${MINIO_USER:-minioadmin}
- MINIO_PASSWORD=${MINIO_PASSWORD:-minioadmin}
depends_on:
minio-bucket-creator:
condition: service_completed_successfully
loki:
image: grafana/loki:3.2.0
restart: unless-stopped
command: -config.file=/etc/loki/local-config.yaml -config.expand-env=true
environment:
- MINIO_USER=${MINIO_USER:-minioadmin}
- MINIO_PASSWORD=${MINIO_PASSWORD:-minioadmin}
depends_on:
minio-bucket-creator:
condition: service_completed_successfully
The MINIO_USER and MINIO_PASSWORD env vars are passed through so Tempo and Loki can authenticate to the S3 API. The -config.expand-env=true flag is critical: without it, Tempo and Loki see the literal string ${MINIO_USER} instead of the actual value, and both crash with InvalidAccessKeyId. Loki supports this flag from v2.1 onward; Tempo follows the same convention.
Prometheus Configuration
The Prometheus OTLP receiver is enabled via the Docker Compose command, and the prometheus.yaml config file sets up resource attribute promotion alongside scrape targets for infrastructure self-monitoring:
# docker-compose.infrastructure.yaml (prometheus service excerpt)
prometheus:
image: prom/prometheus:v3.3.1
restart: unless-stopped
ports:
- '9090:9090'
volumes:
- ./../configs/prometheus.yaml:/etc/prometheus/prometheus.yml
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-otlp-receiver' # ← enables OTLP HTTP ingest
- '--enable-feature=native-histograms'
- '--log.level=info'
The --web.enable-otlp-receiver flag is the critical piece — it turns on the /api/v1/otlp HTTP endpoint that the Collector's otlphttp/prometheus exporter targets. With this flag, Prometheus accepts OTLP metrics directly, no remote-write translation needed.
# deployments/configs/prometheus.yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
collector: otel-collector
# App metrics are pushed from the OpenTelemetry Collector to
# Prometheus through the OTLP HTTP receiver at /api/v1/otlp.
# The receiver is enabled via --web.enable-otlp-receiver.
otlp:
promote_resource_attributes:
- service.instance.id
- service.name
- service.namespace
- service.version
scrape_configs:
- job_name: 'otel-collector'
scrape_interval: 10s
static_configs:
- targets: ['otel-collector:8888']
metric_relabel_configs:
- target_label: integration
replacement: collector-self-metrics
- job_name: 'prometheus'
static_configs:
- targets: ['prometheus:9090']
metric_relabel_configs:
- target_label: integration
replacement: prometheus-self-metrics
- job_name: 'node-exporter'
static_configs:
- targets: ['node-exporter:9100']
metric_relabel_configs:
- target_label: integration
replacement: node-host-metrics
- job_name: 'tempo'
static_configs:
- targets: ['tempo:3200']
metric_relabel_configs:
- target_label: integration
replacement: tempo-self-metrics
The otlp: block promotes key resource attributes from OTLP metadata into Prometheus labels. Every metric arriving via OTLP automatically gets service_name, service_instance_id, and service_version labels — no relabeling needed.
The scrape targets provide self-observability: you can monitor the Collector's own throughput, Prometheus internals, and the host machine's CPU/memory/disk through the node-exporter.
Tempo Configuration (S3-Backed)
Tempo stores trace blocks in MinIO while keeping the write-ahead log on local disk for performance. It receives OTLP traces from the Collector over gRPC and derives RED metrics from spans:
# deployments/configs/tempo.yaml
stream_over_http_enabled: true
server:
http_listen_port: 3200
log_level: info
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: '0.0.0.0:4317'
http:
endpoint: '0.0.0.0:4318'
ingester:
max_block_duration: 5m
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
compactor:
compaction:
block_retention: 1h
metrics_generator:
registry:
external_labels:
source: tempo
cluster: docker-compose
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
traces_storage:
path: /var/tempo/generator/traces
storage:
trace:
backend: s3
s3:
endpoint: minio:9000
bucket: tempo
access_key: ${MINIO_USER}
secret_key: ${MINIO_PASSWORD}
insecure: true
wal:
path: /var/tempo/wal
local:
path: /var/tempo/blocks
overrides:
defaults:
metrics_generator:
processors: [service-graphs, span-metrics, local-blocks]
generate_native_histograms: both
The storage.trace.backend: s3 switch is the only change from local storage. The WAL (/var/tempo/wal) and temporary blocks (/var/tempo/blocks) stay on local disk — Tempo writes completed trace blocks to the tempo bucket on MinIO. The insecure: true flag is needed because MinIO runs without TLS in the local Docker network.
The metrics generator derives RED metrics (Rate, Error, Duration) and service graphs from spans with zero code changes. These are remote-written back to Prometheus, so Grafana shows both application metrics and span-derived metrics in one place. The generate_native_histograms: both flag enables high-precision histogram buckets for latency distributions.
The metrics generator derives RED metrics (Rate, Error, Duration) and service graphs from spans with zero code changes. These are remote-written back to Prometheus, so Grafana shows both application metrics and span-derived metrics in one place. The generate_native_histograms: both flag enables high-precision histogram buckets for latency distributions.
Loki Configuration (S3-Backed)
Loki stores log chunks and the TSDB index in MinIO. The key settings are the storage_config.aws block pointing at MinIO and allow_structured_metadata: true for OTLP attribute preservation:
# deployments/configs/loki-config.yaml
auth_enabled: false
server:
http_listen_port: 3100
common:
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
replication_factor: 1
path_prefix: /tmp/loki
ingester:
chunk_idle_period: 10s
chunk_retain_period: 20s
max_chunk_age: 30s
wal:
enabled: true
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
compactor:
working_directory: /loki/compactor
delete_request_store: s3
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
schema_config:
configs:
- from: 2025-01-01
store: tsdb
object_store: s3
schema: v13
index:
prefix: index_
period: 24h
storage_config:
aws:
endpoint: http://minio:9000
access_key_id: ${MINIO_USER}
secret_access_key: ${MINIO_PASSWORD}
bucketnames: loki
s3forcepathstyle: true
insecure: true
tsdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/index_cache
cache_ttl: 24h
limits_config:
allow_structured_metadata: true
The storage_config.aws block tells Loki to treat MinIO as an S3-compatible endpoint. s3forcepathstyle: true is required because MinIO uses path-style addressing rather than virtual-hosted-style. The ingester section controls how long Loki buffers logs in memory before flushing chunks to S3 — these short durations are fine for development; increase them in production to reduce S3 PUT operations.
The allow_structured_metadata: true flag is essential — without it, Loki strips OTel resource attributes from incoming OTLP log payloads. With it enabled, Grafana can filter logs by service_name, severity_text, and any other attribute pushed through the Collector's otlphttp/loki exporter.
Grafana Datasource Provisioning
Grafana datasources are provisioned declaratively so you never click through the UI:
# deployments/configs/grafana/provisioning/datasources/datasource.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
uid: prometheus-uid
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
editable: false
uid: tempo-uid
jsonData:
httpMethod: GET
tracesToLogsV2:
datasourceUid: loki-uid
spanStartTimeShift: '-5m'
spanEndTimeShift: '5m'
tags:
- key: service_name
value: service_name
tracesToMetrics:
datasourceUid: prometheus-uid
spanStartTimeShift: '-5m'
spanEndTimeShift: '5m'
serviceMap:
datasourceUid: prometheus-uid
streamingEnabled:
search: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: false
uid: loki-uid
jsonData:
derivedFields:
- datasourceUid: tempo-uid
matcherRegex: "^.*?traceI[d|D]=(\\w+).*$"
name: traceId
url: '$${__value.raw}'
- name: Elasticsearch
type: elasticsearch
access: proxy
url: http://elasticsearch:9200
editable: true
uid: elasticsearch-uid
jsonData:
index: 'logs-generic-default'
esVersion: '8.15.2'
timeField: '@timestamp'
maxConcurrentShardRequests: 10
logMessageField: 'body_text'
logLevelField: 'severity_text'
The Tempo-to-Loki link (tracesToLogsV2) enables the classic "click a span → see related logs" workflow. The Loki-to-Tempo derived field extracts traceId from log lines and turns it into a clickable link that opens the full trace waterfall in Tempo.
The Elasticsearch datasource is an optional bridge: it brings logs and metrics from Elasticsearch into Grafana, so you can query both backends without switching tools. For example, you can run a Lucene full-text search against Elasticsearch logs in the same Explore view where you write PromQL and LogQL. Trace waterfalls and service graphs from Elasticsearch are not rendered in Grafana — for those you use Kibana, where Elastic APM provides native trace views, span waterfalls, and dependency maps.
Note on the ELK stack: Elasticsearch and Kibana run side-by-side with the LGTM stack in this article as an optional alternative. In production you would typically pick one observability path — either LGTM (Grafana-native: Prometheus + Tempo + Loki) or ELK (Elastic-native: Elasticsearch + Kibana). Kibana is the Grafana equivalent for the ELK world: it unifies logs (Discover), traces (APM), and metrics (Lens and Dashboards) under one UI — the same role Grafana fills for the LGTM stack. The choice comes down to operational preference: LGTM is lighter and more cloud-native; ELK gives you powerful full-text search, Elastic APM, and machine learning features.

Part 3: Running the Stack
Step 1: Start the Infrastructure
cd deployments/docker-compose
docker compose -f docker-compose.infrastructure.yaml up -d
Wait for all containers to be healthy (docker ps should show all services running).
Step 2: Run the AppHost
cd samples/aspire-shop
dotnet run --project AspireShop.AppHost
The AppHost starts postgres, redis, catalog-db-manager, catalog-service, basket-service, and the frontend. Each service auto-discovers the OTLP endpoint and starts exporting.
Step 3: Generate Traffic
Open the frontend at https://localhost:7241 (or the HTTP endpoint shown in the Aspire dashboard). Browse products, add items to the basket. Every click generates spans, metrics, and logs.
Step 4: Explore in Grafana
Open http://localhost:3000 (login: admin / admin).
Metrics (Prometheus) — Use the Explore view with the Prometheus datasource. Try queries like:
rate(http_server_request_duration_seconds_count[1m])
Traces (Tempo) — Switch to the Tempo datasource. Use TraceQL:
{ .service.name = "catalogservice" }
Click any trace to see the waterfall, then click a span and choose "View related logs" to jump to Loki.
Logs (Loki) — Switch to the Loki datasource. Use LogQL:
{service_name="catalogservice"} |= ""
Log lines with trace IDs will have clickable links back to Tempo.
Step 5: Explore the Optional ELK Stack
Open http://localhost:5601 for Kibana. Think of Kibana as the Grafana equivalent for the ELK stack — one UI that covers all three signals:
| Signal | Kibana Tool | Grafana Equivalent |
|---|---|---|
| Logs | Discover | Loki Explore view |
| Traces | APM | Tempo trace waterfall |
| Metrics | Lens / Dashboards | Prometheus + Grafana panels |
Start with Analytics > Discover and select the logs-generic-default data view. You will see the same logs flowing from the Collector, with Lucene full-text search and field-level filtering. For traces, head to APM > Services — the Collector pushes trace data to Elasticsearch with mapping: mode: otel, and Elastic APM surfaces service maps, latency distributions, and span waterfalls. For metrics, Dashboards and Lens let you build visualizations from Elasticsearch aggregations without learning a separate query language.
Back in Grafana, the Elasticsearch datasource (elasticsearch-uid) gives you a bridge: query Elasticsearch logs and metrics without leaving Grafana's interface. Use it when you want Elastic's full-text search power inside your existing Grafana dashboards.
One stack or two? Running both LGTM and ELK side by side is fine for learning, but in production pick one. Both stacks give you logs, traces, and metrics. The difference is where the data lives (object storage vs. Elasticsearch) and which UI you prefer (Grafana vs. Kibana).
Part 4: Understanding the Signal Flow
Let's trace a single user request through the entire pipeline:
%%{init: {'theme': 'base', 'themeVariables': {
'actorBkg': '#4A90D9', 'actorBorder': '#2E6BA9', 'actorTextColor': '#fff',
'actorLineColor': '#4A90D9', 'signalColor': '#888', 'signalTextColor': '#333',
'labelBoxBkgColor': '#FFF3CD', 'labelBoxBorderColor': '#F1C40F', 'labelTextColor': '#333',
'noteBkgColor': '#E8F8F5', 'noteBorderColor': '#1ABC9C', 'noteTextColor': '#333',
'activationBkgColor': '#D6EAF8', 'activationBorderColor': '#4A90D9',
'sequenceNumberColor': '#fff'
}}}%%
sequenceDiagram
participant U as Browser
participant FW as Frontend
participant CS as CatalogService
participant COL as OTel Collector
participant T as Tempo
participant L as Loki
participant P as Prometheus
participant G as Grafana
U->>FW: GET /catalog
FW->>CS: gRPC GetCatalogItems
CS-->>FW: CatalogItem list
FW-->>U: HTML page
Note over FW,CS: Each service emits OTLP to Collector
FW->>COL: spans + metrics + logs
CS->>COL: spans + metrics + logs
COL->>T: traces
COL->>L: logs
COL->>P: metrics
G->>T: query traces
G->>L: query logs
G->>P: query metrics
- A request hits the Frontend, which calls CatalogService over gRPC.
- Both services emit spans (with trace context propagation), metrics (request duration, rate), and logs (ILogger entries).
- The Collector receives everything on
:4317, batches it, tags it withenvironment=development, and fans it out:- Traces → Tempo (gRPC)
- Metrics → Prometheus (OTLP HTTP)
- Logs → Loki (OTLP HTTP) + Elasticsearch (native export)
- Grafana queries Tempo, Loki, Prometheus, and Elasticsearch through their respective datasources.
The trace context flows automatically because AddAspNetCoreInstrumentation and AddHttpClientInstrumentation handle W3C trace context propagation. gRPC calls propagate trace context via gRPC metadata.
Part 5: Choosing Between LGTM and ELK
Both stacks offer complete observability. Which one should you choose?
| Concern | LGTM (Prometheus + Tempo + Loki) | ELK (Elasticsearch + Kibana) |
|---|---|---|
| Metrics | Prometheus — de-facto standard for cloud-native metrics, PromQL, Alertmanager | Elasticsearch aggregations + Kibana Lens |
| Traces | Tempo — TraceQL, service graphs, no sampling required at ingest | Elastic APM — tight integration with Elasticsearch queries |
| Logs | Loki — low-cost, label-based, integrates with Promtail/OTel | Elasticsearch — powerful full-text search, Lucene query syntax |
| Storage | Object storage (S3/GCS/Azure) — cheap, scales horizontally | Local or cloud disks — requires more tuning for scale |
| Learning curve | Moderate — three different query languages (PromQL, TraceQL, LogQL) | Lower — one query language (Lucene/KQL), one store |
| Best for | Kubernetes-native, microservices, teams already using Prometheus | Full-text log analysis, security/APM use cases, single-vendor preference |
The AspireShop sample runs both side by side for demonstration. In a real project, pick one and keep your operational surface area smaller.
Conclusion
You have wired a .NET microservices application for full observability using open standards and open-source tooling. The recipe:
- .NET Aspire + Service Defaults gives you OTel instrumentation with zero-config defaults.
- AppHost environment injection keeps the OTLP endpoint configurable per environment.
- OTel Collector acts as a universal telemetry router — receive once, export everywhere.
- LGTM stack (Prometheus, Tempo, Loki, Grafana) provides a modern, scalable observability backend.
- ELK stack (Elasticsearch, Kibana) runs as an optional sidecar for teams that prefer Elastic-native tooling.
- Grafana ties everything together with cross-datasource linking (trace → logs, logs → trace).
The complete source code is available at github.com/mehdihadeli/blog-samples under samples/aspire-shop/. All Docker Compose files, Collector configs, and Grafana provisioning files are in the deployments/ folder — ready to run with a single docker compose up.


