Monitoring and Observability in ASP.NET Core: Metrics, Logging, and Distributed Tracing
Build production-ready ASP.NET Core applications with comprehensive monitoring, structured logging, and distributed tracing strategies
Modern applications demand visibility into their runtime behavior, especially as they scale across distributed architectures. Monitoring and observability in ASP.NET Core gives you the insights needed to understand system health, diagnose issues quickly, and maintain reliable services.
This comprehensive guide explores how to implement metrics collection, structured logging, and distributed tracing in your ASP.NET Core applications, transforming opaque systems into transparent, debuggable services that your team can confidently operate at scale.
The difference between a system that's monitored and one that's observable is like the difference between a car's dashboard and a full diagnostic computer. Your dashboard tells you speed, fuel level, and engine temperature (useful metrics that alert you to problems). But when something goes wrong, you need the diagnostic computer to tell you exactly which sensor failed, what conditions led to the failure, and how the issue cascaded through interconnected systems. That's observability, and it's become essential for modern application development.
ASP.NET Core provides robust built-in support for observability through its logging framework, metrics APIs, and integration with distributed tracing standards. When you combine these three pillars (metrics, logging, and tracing) you create a comprehensive view of your application's behavior that makes troubleshooting straightforward and performance optimization data-driven.
Understanding the Three Pillars of Observability
Observability isn’t just monitoring with a fancier name. It represents a fundamental shift in how we think about understanding system behavior. Traditional monitoring asks “Is the system working?” Observability asks “Why is the system behaving this way?” This distinction becomes critical when dealing with microservices architectures where a single user request might touch dozens of services.
Metrics provide the quantitative measurements of your system: request rates, error counts, response times, memory usage, and custom business metrics. These numbers give you the high-level view of system health and performance trends over time. When you see your error rate spike at 2 AM, metrics tell you something went wrong. They’re your early warning system.
Logging captures discrete events that happen within your application. Each log entry represents something specific that occurred: a user logged in, a database query failed, an external API returned an unexpected response. Logs provide the narrative of what your application did and why. When metrics alert you to that 2 AM error spike, logs tell you what actually failed.
Distributed tracing tracks individual requests as they flow through your system. In a microservices architecture where a single API call might trigger twenty service-to-service communications, tracing shows you the complete journey. It reveals which service slowed down, where errors originated, and how latency compounds across service boundaries. Tracing answers the “where” question when something goes wrong.
Together, these three pillars transform your application from a black box into a transparent system you can understand, debug, and optimize with confidence. The .NET documentation on observability provides extensive resources for implementing these patterns, and the investment pays dividends the first time you troubleshoot a production issue in minutes instead of hours.
Implementing Structured Logging in ASP.NET Core
Structured logging revolutionized how we capture and analyze application events. Instead of writing plain text messages that require regex parsing and prayer to analyze, structured logging captures events as strongly-typed data with semantic meaning. This makes logs searchable, filterable, and vastly more useful for both humans and automated analysis tools.
ASP.NET Core’s built-in logging framework supports structured logging right out of the box through the ILogger interface. The framework uses template strings with named placeholders rather than string interpolation, which preserves the semantic structure of your log data. When you write a log message, you’re not just recording text; you’re recording structured data points that logging systems can index and query.
The basic logging setup in ASP.NET Core happens automatically through the host builder, but you’ll want to configure it properly for production use. The framework supports multiple logging providers simultaneously, letting you write to the console during development, to files in production, and to centralized logging systems for analysis. The key is configuring log levels appropriately so you capture enough information without overwhelming your logging infrastructure.
Your Program.cs should configure logging providers based on your environment. During development, console logging works perfectly. In production, you’ll typically configure providers for your chosen logging platform, whether that’s Application Insights, Elasticsearch, Seq, or another structured logging system. The configuration is straightforward and doesn’t require application code changes when moving between environments.
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
if (builder.Environment.IsProduction())
{
builder.Logging.AddApplicationInsights();
}
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);
builder.Logging.AddFilter("System", LogLevel.Warning);The real power of structured logging emerges when you log with semantic context. Instead of concatenating strings, use message templates with named parameters. This preserves the structure of your data and makes it queryable in your logging system. When you log a user action, capture the user ID, action type, and relevant entity IDs as structured properties rather than embedding them in a text string.
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
_logger.LogInformation(
"Creating order for customer {CustomerId} with {ItemCount} items totaling {OrderTotal}",
request.CustomerId,
request.Items.Count,
request.Items.Sum(i => i.Price * i.Quantity));
try
{
var order = await ProcessOrderAsync(request);
_logger.LogInformation(
"Successfully created order {OrderId} for customer {CustomerId}",
order.Id,
request.CustomerId);
return order;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to create order for customer {CustomerId}",
request.CustomerId);
throw;
}
}
}Log levels serve as your first line of defense against log overload. Trace and Debug logs should contain detailed information useful during development but too verbose for production. Information level captures normal application flow and business events. Warning indicates something unexpected but not necessarily wrong. Error captures failures, and Critical represents catastrophic issues requiring immediate attention. Setting appropriate log levels in production keeps your logging costs manageable while ensuring you capture everything needed for troubleshooting.
Enriching logs with context makes them infinitely more useful. Consider using scopes to add contextual information that applies to a set of related log entries. When processing a specific request, create a logging scope that includes the correlation ID, user ID, and other relevant context. Every log entry within that scope automatically includes this information, making it trivial to filter logs to a specific request or user session.
public async Task<IActionResult> ProcessPayment(PaymentRequest request)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = HttpContext.TraceIdentifier,
["UserId"] = User.FindFirst(ClaimTypes.NameIdentifier)?.Value,
["PaymentMethod"] = request.PaymentMethod
}))
{
_logger.LogInformation("Processing payment for amount {Amount}", request.Amount);
// All logs in this scope include the correlation ID, user ID, and payment method
var result = await _paymentProcessor.ProcessAsync(request);
return Ok(result);
}
}Sensitive data poses a particular challenge for logging. You need detailed logs for troubleshooting, but you can’t log personally identifiable information or security credentials. The solution is redaction, that is automatically scrubbing sensitive data from logs before they’re written. You can implement custom log enrichers that detect and redact fields like credit card numbers, social security numbers, or authentication tokens. Many structured logging frameworks include built-in redaction capabilities.
Collecting and Exposing Metrics
Metrics transform your application from a system you react to into one you understand proactively. Instead of waiting for users to report issues, metrics let you spot degrading performance, increasing error rates, or resource exhaustion before they impact users. ASP.NET Core includes comprehensive metrics support through the System.Diagnostics.Metrics API and integration with standards-based collection systems.
The .NET metrics system uses a push-pull hybrid model. Your application code pushes measurements to Meter instances, while external collectors pull metrics data at regular intervals for aggregation and analysis. This design keeps metrics collection lightweight and flexible. Your application code doesn’t need to know anything about how metrics will be collected, stored, or visualized.
Built-in ASP.NET Core metrics cover the most common observability needs right out of the box. The framework automatically tracks HTTP request duration, request rates, active requests, and response status codes. These metrics give you immediate visibility into application throughput and health without writing a single line of metrics code. The ASP.NET Core metrics documentation details all available built-in metrics.
Custom metrics let you track business-specific measurements that matter for your application. Maybe you need to monitor payment processing rates, inventory levels, or user engagement metrics. The Meter API makes creating custom metrics straightforward and consistent with framework metrics.
public class OrderMetrics
{
private readonly Counter<int> _ordersCreated;
private readonly Histogram<double> _orderValue;
private readonly ObservableGauge<int> _pendingOrders;
private readonly OrderRepository _repository;
public OrderMetrics(IMeterFactory meterFactory, OrderRepository repository)
{
var meter = meterFactory.Create("MyApp.Orders");
_ordersCreated = meter.CreateCounter<int>(
"orders.created",
unit: "orders",
description: "Number of orders created");
_orderValue = meter.CreateHistogram<double>(
"orders.value",
unit: "USD",
description: "Order value distribution");
_pendingOrders = meter.CreateObservableGauge<int>(
"orders.pending",
() => _repository.GetPendingCount(),
unit: "orders",
description: "Number of orders pending fulfillment");
_repository = repository;
}
public void RecordOrderCreated(Order order)
{
_ordersCreated.Add(1, new KeyValuePair<string, object>("status", order.Status));
_orderValue.Record(order.Total);
}
}Different metric types serve different purposes. Counters track values that only increase (request counts, error counts, items processed). Histograms capture distributions of values (request durations, payload sizes, queue depths). Gauges represent point-in-time measurements that can go up or down (active connections, memory usage, queue length). Choose the right metric type for what you’re measuring to ensure accurate aggregation and meaningful visualizations.
Tags add dimensions to your metrics, enabling fine-grained filtering and grouping during analysis. When you record a counter increment, you can attach tags for the HTTP method, response status code, or endpoint route. This lets you answer questions like “What’s my error rate for POST requests?” or “Which endpoint has the slowest average response time?” Tags transform simple metrics into multidimensional datasets you can slice and dice for detailed analysis.
Exposing metrics for collection typically happens through the /metrics endpoint using a standard format like Prometheus. ASP.NET Core integrates seamlessly with OpenTelemetry, which provides standardized metrics collection that works with virtually any metrics platform. The OpenTelemetry .NET SDK handles the heavy lifting of exposing metrics in the right format for your chosen platform.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
});
var app = builder.Build();
app.MapPrometheusScrapingEndpoint();Cardinality is the silent killer of metrics systems. Every unique combination of tag values creates a new time series in your metrics database. If you tag metrics with unbounded values like user IDs or transaction IDs, you’ll create millions of time series that overwhelm your metrics infrastructure. Keep tag values bounded to a reasonable set (use categories, not unique identifiers). Tag with the endpoint route, not the full URL with query parameters.
Implementing Distributed Tracing
Distributed tracing brings order to the chaos of microservices debugging. When a user request flows through ten different services, each potentially calling multiple databases and external APIs, understanding what happened requires more than logs and metrics. Tracing stitches together the complete story of a request’s journey through your system, showing timing, dependencies, and failure points across service boundaries.
The W3C Trace Context standard provides the foundation for modern distributed tracing. It defines how trace context propagates across service boundaries through HTTP headers, ensuring that every service participating in a request can correlate their activities back to the original request. ASP.NET Core implements this standard automatically, making distributed tracing work with minimal configuration.
Every trace consists of spans representing individual operations. The root span represents the entire request, like an API call to your system. Child spans represent work done to fulfill that request, like the database queries, calls to other services, computation-heavy operations. The parent-child relationship between spans creates a tree structure that visualizes request flow and makes it obvious where time is spent and where errors occur.
Setting up distributed tracing in ASP.NET Core leverages OpenTelemetry, the industry-standard observability framework. OpenTelemetry provides automatic instrumentation for ASP.NET Core, HttpClient, and popular libraries, meaning you get basic tracing without instrumenting your code. The framework automatically creates spans for incoming requests, outgoing HTTP calls, and database operations when you use supported libraries.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation()
.AddSource("MyApp.*");
if (builder.Environment.IsDevelopment())
{
tracing.AddConsoleExporter();
}
else
{
tracing.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(builder.Configuration["OpenTelemetry:Endpoint"]);
});
}
});Custom spans let you instrument specific operations that matter for your application. Maybe you have a complex business workflow that executes multiple operations, or you’re integrating with a third-party system that doesn’t support automatic instrumentation. Creating custom spans gives you visibility into these operations within the broader request trace.
public class InventoryService
{
private readonly ActivitySource _activitySource;
private readonly IInventoryRepository _repository;
public InventoryService(IInventoryRepository repository)
{
_activitySource = new ActivitySource("MyApp.Inventory");
_repository = repository;
}
public async Task<bool> ReserveInventoryAsync(string productId, int quantity)
{
using var activity = _activitySource.StartActivity("ReserveInventory");
activity?.SetTag("product.id", productId);
activity?.SetTag("quantity", quantity);
try
{
var available = await _repository.GetAvailableQuantityAsync(productId);
activity?.SetTag("inventory.available", available);
if (available < quantity)
{
activity?.SetStatus(ActivityStatusCode.Error, "Insufficient inventory");
return false;
}
await _repository.ReserveAsync(productId, quantity);
activity?.SetStatus(ActivityStatusCode.Ok);
return true;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}Span attributes provide context about the operation a span represents. Adding tags like user IDs, product IDs, or operation parameters makes traces searchable and helps you understand what happened during a specific request. When investigating an issue, you can filter traces by these attributes to find relevant examples, like all traces where inventory reservation failed for a specific product.
Sampling controls which traces you collect and store. Recording every single trace in a high-traffic system generates massive amounts of data and costs accordingly. Sampling lets you capture a representative sample of traces while keeping costs manageable. You might sample 1% of successful requests but 100% of failed requests, ensuring you have data for troubleshooting issues while limiting data volume.
Trace correlation ties together logs, metrics, and traces for the same request. When OpenTelemetry is configured, it automatically adds trace IDs and span IDs to log scope, meaning every log entry includes correlation identifiers. In your logging platform, you can jump from a trace to all related logs, or from a log entry to the complete trace. This correlation eliminates the guesswork from troubleshooting. You see the complete picture of what happened during a specific request.
Choosing and Configuring Observability Platforms
The observability tools you choose shape how effectively you can operate and debug your applications. Dozens of platforms compete in this space, from open-source solutions like Prometheus and Grafana to commercial offerings like Application Insights, Datadog, and New Relic. The right choice depends on your specific needs, existing infrastructure, and whether you’re running in the cloud or on-premises.
Application Insights integrates seamlessly with ASP.NET Core and Azure infrastructure, making it a natural choice for applications hosted on Azure. It provides automatic instrumentation, powerful query capabilities through Kusto Query Language, and tight integration with other Azure services. The SDK captures detailed telemetry with minimal configuration, and the platform excels at application performance monitoring and dependency tracking.
Open-source stacks based on Prometheus for metrics, Loki for logs, and Tempo or Jaeger for traces provide complete observability without vendor lock-in. These tools work well together and integrate with Grafana for unified visualization. This approach gives you complete control over your observability infrastructure and works across any hosting environment, though it requires more operational overhead to maintain.
Commercial platforms like Datadog and New Relic offer comprehensive observability with minimal operational burden. They provide hosted infrastructure, sophisticated analytics, and extensive integrations with cloud platforms and third-party services. The tradeoff is cost, which can become significant at scale, and potential vendor lock-in if you build dashboards and alerts specific to one platform.
Configuration should differ between development, staging, and production environments. In development, console exporters let you see metrics, logs, and traces directly in your terminal without additional infrastructure. Staging should mirror production configuration to catch integration issues before deployment. Production requires robust, scalable infrastructure with appropriate retention policies and sampling rates.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService("MyApp")
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] = builder.Environment.EnvironmentName,
["service.version"] = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
}))
.WithMetrics(metrics => ConfigureMetrics(metrics, builder))
.WithTracing(tracing => ConfigureTracing(tracing, builder));Resource attributes identify your service in observability platforms. Setting the service name, version, and environment ensures you can filter and correlate telemetry correctly. When you have multiple services sending data to the same platform, these attributes let you identify which service generated specific metrics, logs, or traces.
Building Effective Dashboards and Alerts
Dashboards transform raw observability data into actionable insights. The best dashboards answer specific questions. Is the system healthy? Where is performance degrading? What’s the user experience? Bad dashboards show every metric you collect without purpose or context, overwhelming viewers with data but providing no insight.
Start with the golden signals: latency, traffic, errors, and saturation. These four metrics, popularized by Google’s Site Reliability Engineering practices, provide a comprehensive health check for any service. Latency shows how long operations take. Traffic indicates request volume. Errors reveal failure rates. Saturation shows resource utilization. Monitor these for every service, and you’ll catch most issues before they escalate.
Latency metrics should use percentiles rather than averages. The average response time might be perfectly acceptable while the 95th or 99th percentile is terrible, meaning some users experience horrible performance while most have a fine experience. Track p50, p95, and p99 latencies to understand the full distribution of user experience. The Google SRE Book explains this concept in detail.
Error rate dashboards should show both absolute counts and percentages. A spike in error count might be concerning, or it might just reflect increased traffic with the same error rate. Showing both the raw count and the error percentage lets you distinguish between these scenarios. Break down errors by type when possible; 4xx client errors versus 5xx server errors tell very different stories.
Traffic patterns reveal system behavior and capacity needs. Dashboards showing requests per second over time help you identify normal patterns, peak loads, and unexpected spikes. Combining traffic metrics with latency and error rates shows how your system performs under different load levels and helps you identify when you’re approaching capacity limits.
Saturation metrics track resource utilization, like the CPU, memory, disk I/O, network bandwidth, database connections. Systems under resource pressure degrade gracefully until they don’t, then they fail catastrophically. Monitoring saturation lets you add capacity before you hit those failure points. Track both current utilization and rate of change to predict when you’ll exhaust resources.
Alerts should be actionable, not just informative. Every alert should indicate a problem that requires immediate attention and should include enough context to begin troubleshooting. Alert fatigue kills effective on-call rotations. If alerts fire constantly for non-issues, people learn to ignore them. Configure thresholds carefully, use alert aggregation to prevent notification storms, and ruthlessly eliminate alerts that don’t represent real problems.
// Example: Custom health check for alert conditions
public class SystemHealthCheck : IHealthCheck
{
private readonly IMetricsReader _metrics;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var errorRate = await _metrics.GetErrorRateAsync();
var latencyP95 = await _metrics.GetLatencyPercentileAsync(95);
var activeConnections = await _metrics.GetActiveConnectionsAsync();
if (errorRate > 0.05) // 5% error rate
{
return HealthCheckResult.Unhealthy(
$"Error rate {errorRate:P2} exceeds threshold");
}
if (latencyP95 > TimeSpan.FromSeconds(2))
{
return HealthCheckResult.Degraded(
$"P95 latency {latencyP95.TotalMilliseconds}ms exceeds threshold");
}
return HealthCheckResult.Healthy();
}
}Best Practices for Production Observability
Production observability requires careful planning around costs, performance impact, and data retention. Unlimited observability data sounds great until you receive the monthly bill or discover that metrics collection is consuming 10% of your CPU budget. Strategic decisions about what to collect, how often to collect it, and how long to retain it keep observability sustainable.
Cost management starts with sampling and aggregation. You don’t need to record every single request trace in a system handling millions of requests per day. Sample based on request characteristics. For instance, capture all errors and slow requests, but only a percentage of successful fast requests. This gives you complete visibility into problems while limiting data volume for routine operations.
Cardinality control prevents metrics explosions. Every unique combination of metric name and tag values creates a new time series in your metrics database. If you add a tag with a million possible values, you create a million time series, overwhelming your metrics infrastructure. Keep tag cardinality bounded by using categories instead of unique identifiers and avoiding timestamp-like values in tags.
Performance impact from observability instrumentation should be minimal, typically under 5% overhead. Modern instrumentation libraries achieve this through careful optimization, but you can still introduce performance problems through excessive custom instrumentation. Avoid creating spans or logging inside tight loops, and be strategic about what you measure. Profile your instrumentation code just like you profile application code.
Data retention policies balance historical analysis needs against storage costs. Detailed raw data might be retained for days or weeks, while aggregated data can be kept for months or years. Configure different retention periods for different data types. That is, keep traces for a few days since they’re high volume and primarily useful for recent debugging, but keep aggregated metrics for months to analyze trends.
Security and compliance considerations affect observability design. Logs and traces might contain sensitive data that requires redaction or encryption. Some regulations require specific retention periods or data residency requirements for audit logs. Design your observability pipeline with these requirements in mind rather than trying to retrofit compliance later.
Standardization across services makes observability data more useful. When every service uses the same metric names, log formats, and trace attributes for common concerns, you can build unified dashboards and alerts. Establish conventions for your organization (standard tag names, consistent error categorization, shared metric naming schemes). This consistency compounds in value as your system grows.
Common Observability Challenges and Solutions
Correlating data across observability pillars presents a persistent challenge. You see an error spike in metrics, but which logs contain details about those errors? You have a slow trace, but which metrics would have predicted this issue? The solution is consistent correlation identifiers. Trace IDs, request IDs, and correlation IDs that tie together metrics, logs, and traces for the same request or transaction.
High cardinality problems emerge when unbounded tag values create millions of unique time series. The solution is careful tag design. Use bounded categories instead of unique identifiers, aggregate at ingest time when possible, and drop tags that don’t add analytical value. Some platforms offer cardinality controls that automatically limit or aggregate high-cardinality dimensions.
Missing context in logs makes troubleshooting painful. You see an error message, but you don’t know which user encountered it, which code path led to it, or what data caused it. Structured logging with rich context solves this. Include correlation IDs, user IDs, entity IDs, and operation parameters in log scope. This transforms cryptic error messages into actionable debugging information.
Excessive noise from chattiness overwhelms observability systems and alert channels. Not every log entry deserves to be written at Information level, and not every metric change deserves an alert. Tune log levels appropriately for each environment, use appropriate sampling for high-frequency events, and configure alert thresholds that reflect actual problems rather than normal system behavior.
Performance impact from instrumentation occasionally becomes noticeable, especially in high-throughput code paths. The solution is selective instrumentation. Don’t create custom spans inside loops, avoid synchronous logging calls in critical paths, and use sampling for high-frequency operations. Measure the impact of your instrumentation code and optimize hotspots.
Data volume costs can surprise teams new to comprehensive observability. The solution combines sampling, aggregation, and retention policies. Sample traces intelligently, aggregate metrics at collection time, and configure shorter retention for detailed data while keeping aggregated views longer. Most observability platforms offer volume controls and cost calculators to help you optimize spending.
Join the Community
Implementing comprehensive observability transforms how you build and operate ASP.NET Core applications. The insights from metrics, logs, and traces enable confident deployments, rapid troubleshooting, and data-driven optimization decisions.
Ready to level up your ASP.NET Core expertise? Subscribe to ASP Today for weekly deep-dives into practical development techniques, architectural patterns, and real-world solutions. Join the conversation in Substack Chat where developers share experiences, ask questions, and learn from each other.


