Getting started with .NET metrics collection
When you're running ASP.NET Core applications in production, you need visibility into what's actually happening—not just error logs, but the steady pulse of your system. Monitoring .NET apps: a practical guide for developers means shifting from guesswork to data-driven decisions about performance, resource usage, and application health. In this guide, we'll walk through instrumenting your ASP.NET Core services with OpenTelemetry and Prometheus to expose the metrics that matter.
The good news is that ASP.NET Core ships with built-in instrumentation for many of the metrics you'll need: request latencies, garbage collection pauses, thread pool statistics, and more. We just need to wire them up correctly and export them to Prometheus.
OpenTelemetry with Prometheus exporter
OpenTelemetry is the industry standard for collecting metrics, traces, and logs. For metrics specifically, it provides a unified API and a Prometheus exporter that integrates seamlessly with the Prometheus scraping model.
Start by installing the necessary NuGet packages:
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Exporter.Prometheus
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.RuntimeNext, configure OpenTelemetry in your Program.cs:
using OpenTelemetry.Metrics;
var builder = WebApplication.CreateBuilder(args);
var meterProvider = new MeterProviderBuilder()
.AddPrometheusExporter()
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.Build();
builder.Services.AddSingleton(meterProvider);
var app = builder.Build();
app.UseOpenTelemetryPrometheusScrapingEndpoint();
app.MapGet("/", () => "Hello World!");
app.Run();The AddPrometheusExporter() call tells OpenTelemetry to format metrics in Prometheus text format. By default, this exposes metrics at /metrics. The UseOpenTelemetryPrometheusScrapingEndpoint() middleware registers that endpoint on the default HTTP port.
At this point, you can start your application and hit http://localhost:5000/metrics to see raw Prometheus output. You'll see metrics like http_server_request_duration_ms and process_runtime_dotnet_gc_collections_count already being collected.
Built-in ASP.NET Core and runtime meters
ASP.NET Core provides out-of-the-box instrumentation through the System.Diagnostics.Metrics API. When you add AddAspNetCoreInstrumentation() to your meter provider, you automatically get metrics for:
http_server_request_duration_ms: latency distribution of all HTTP requestshttp_server_active_requests: number of in-flight requestshttp_server_request_size_bytes: request body sizeshttp_server_response_size_bytes: response body sizes
These metrics come tagged with dimensions like http.method, http.status, and http.route, so you can slice and dice your data in Prometheus queries.
For runtime metrics, AddRuntimeInstrumentation() exposes:
- GC collections and pauses
- Heap allocations
- Thread pool work queue depth
- CPU and memory usage
Here's what a typical Prometheus query looks like after a few minutes of traffic:
http_server_request_duration_ms_bucket{http_method="GET",http_status="200",le="100"}
http_server_request_duration_ms_bucket{http_method="GET",http_status="200",le="250"}
http_server_request_duration_ms_bucket{http_method="GET",http_status="200",le="500"}These buckets let you compute percentiles in Prometheus—for example, histogram_quantile(0.99, http_server_request_duration_ms_bucket) gives you the 99th percentile latency.
Track GC and thread pool
Garbage collection and thread pool behavior are often the root cause of performance issues in .NET applications, yet many teams overlook them until fire-fighting mode. By exposing these metrics to Prometheus, you can spot problems early.
The runtime instrumentation automatically collects GC metrics, but understanding what you're looking at is crucial. Here are the key ones:
process_runtime_dotnet_gc_collections_count: number of GC collections per generation (Gen 0, Gen 1, Gen 2)process_runtime_dotnet_gc_heap_size_bytes: current heap sizeprocess_runtime_dotnet_gc_pause_seconds: time spent paused during GC
If you see frequent Gen 2 collections or increasing GC pause times, that's a signal to profile your heap allocations. A simple Grafana alert on GC pause duration can wake you up before users complain:
alert: HighGCPause
expr: rate(process_runtime_dotnet_gc_pause_seconds_total[5m]) > 0.1
for: 5m
annotations:
summary: "GC pause time exceeding 100ms on {{ $labels.instance }}"For thread pool metrics, you'll find:
process_runtime_dotnet_threadpool_work_item_queue_length: items waiting for executionprocess_runtime_dotnet_threadpool_threads_count: current thread pool sizeprocess_runtime_dotnet_threadpool_completed_items_count: total work items processed
A growing work queue length suggests your application is under-provisioned or has a bottleneck. Combined with request latency metrics, this paints a clear picture: if latency climbs and queue length climbs together, you need more throughput capacity.
Expose /metrics on an internal port
In production, you don't want to expose your metrics endpoint on the same port as your public API. This is both a security and operational best practice: metrics can reveal information about your infrastructure, and you want them accessible to internal monitoring systems only.
The cleanest approach is to run a separate listener on an internal port:
var builder = WebApplication.CreateBuilder(args);
var meterProvider = new MeterProviderBuilder()
.AddPrometheusExporter()
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.Build();
builder.Services.AddSingleton(meterProvider);
// Public API app
var app = builder.Build();
app.MapGet("/api/health", () => "OK");
app.Run("http://localhost:5000");
// Metrics app on internal port
var metricsApp = builder.CreateBuilder().Build();
metricsApp.UseOpenTelemetryPrometheusScrapingEndpoint();
metricsApp.Run("http://localhost:9090");
await Task.WhenAll(app.RunAsync(), metricsApp.RunAsync());Now your /metrics endpoint lives on port 9090, which you only expose to your Prometheus instance (typically via service mesh mTLS or network policy). Your public API remains on port 5000.
In your Prometheus config, scrape both:
scrape_configs:
- job_name: 'myapp-public'
static_configs:
- targets: ['localhost:5000']
- job_name: 'myapp-metrics'
static_configs:
- targets: ['localhost:9090/metrics']Common mistakes
Not setting up histogram bucket boundaries. By default, Prometheus histogram buckets may not align with your SLOs. If your SLO is 200ms p99, but your buckets are [50, 100, 250, 500, 1000], you'll have a hard time validating compliance. You can customize buckets in the exporter options.
Forgetting to dispose the meter provider. The meter provider holds resources like active timers and background threads. In long-running applications, neglecting disposal can leak memory. Always register it with the dependency injection container or explicitly call Dispose() on shutdown.
Collecting metrics you don't need. Every metric you collect increases cardinality and scrape latency. If you're not going to alert on or dashboard a metric, don't collect it. You can selectively enable instrumentation layers.
Exposing metrics on the same port as your API without authentication. This is a common oversight in development that slips into production. Always isolate metrics on an internal port or add middleware to require authentication.
Key takeaways
- OpenTelemetry with the Prometheus exporter is the modern standard for metrics collection in .NET; it requires just a few lines of setup code.
- ASP.NET Core and runtime instrumentation provide rich, tagged metrics out of the box: request latencies, GC behavior, and thread pool depth.
- Garbage collection and thread pool metrics are early warning signs of capacity or allocation problems; monitor them alongside application metrics.
- Expose metrics on a dedicated internal port to keep your public API surface clean and secure.
- Understand your histogram buckets and SLOs before alerting; default buckets may not match your compliance requirements.






Comments
Loading comments…