.NET Core
Logging and Monitoring in .NET: Serilog and Application Insights
Observability is critical for running .NET systems in production. Logging alone is not enough; you need logs, metrics, and traces that connect into a coherent debugging story. In production systems I've observed that teams without structured observability spend 3-5x longer on incident resolution compared to those with a well-designed logging and monitoring pipeline.
This article covers practical patterns for building a robust observability foundation using Serilog, Application Insights, and OpenTelemetry in .NET.
Structured Logging with Serilog
Why Structured Logging Matters
Plain text logs like "User 42 placed order 1001" are human-readable but machine-hostile. When you have thousands of requests per second flowing through multiple services, you need logs that are searchable, filterable, and aggregatable.
Structured logging captures data as key-value properties alongside the message, making it possible to query logs like a database.
Serilog Setup
A production-ready Serilog configuration goes beyond the defaults. Here is a baseline setup I use in most .NET services:
using Serilog;
using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("ServiceName", "OrderApi")
.WriteTo.Console(theme: AnsiConsoleTheme.Code)
.WriteTo.ApplicationInsights(
telemetryConfiguration,
TelemetryConverter.Traces)
.WriteTo.Async(a => a.File(
path: "logs/order-api-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
fileSizeLimitBytes: 100_000_000))
.CreateLogger();Register it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
var app = builder.Build();
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
};
});Message Templates Done Right
Serilog uses message templates, not string interpolation. This distinction is critical:
// WRONG - string interpolation destroys structure
_logger.LogInformation($"Order {orderId} created by user {userId}");
// CORRECT - message template preserves named properties
_logger.LogInformation("Order {OrderId} created by user {UserId}", orderId, userId);
// CORRECT - destructure complex objects with @
_logger.LogInformation("Order placed: {@OrderDetails}", new { orderId, userId, total, itemCount });With the correct approach, you can later query WHERE OrderId = 1001 in your log aggregator. With string interpolation, that property is lost inside a text blob.
Enrichers and Contextual Logging
Enrichers automatically attach properties to every log event within a scope. This is invaluable for tracing a request across its lifecycle:
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Response.Headers["X-Correlation-ID"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}In production systems I've observed that without correlation IDs, debugging cross-service issues becomes a guessing game. A single correlation ID that flows from API gateway through every downstream service call is the single most impactful observability investment you can make.
Custom Enrichers
For recurring context, build a reusable enricher:
public class TenantEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantEnricher(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var tenantId = _httpContextAccessor.HttpContext?.Items["TenantId"]?.ToString();
if (!string.IsNullOrEmpty(tenantId))
{
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("TenantId", tenantId));
}
}
}Structured Logging Best Practices
Use the Right Log Level
Each level has a purpose. Misusing them creates noise or hides critical information:
- Verbose/Trace: Internal framework details. Almost never enabled in production.
- Debug: Diagnostic detail useful during development. Enable per-service when troubleshooting.
- Information: Business-significant events. "Order created", "Payment processed", "User logged in".
- Warning: Something unexpected happened but the system recovered. Retry succeeded, cache miss fallback.
- Error: An operation failed. The request could not be completed, but the service is still running.
- Fatal/Critical: The process is about to crash. Unrecoverable state, OOM, corrupted configuration.
// Good: clear business event at Information level
_logger.LogInformation("Payment {PaymentId} processed for order {OrderId}, amount {Amount:C}",
paymentId, orderId, amount);
// Good: warning for recoverable issue
_logger.LogWarning("Cache miss for product {ProductId}, falling back to database", productId);
// Good: error with full exception context
_logger.LogError(ex, "Failed to process payment {PaymentId} for order {OrderId}", paymentId, orderId);Keep Log Volume Under Control
In production systems I've observed services generating 50GB+ of logs per day because developers logged every loop iteration at Information level. High log volume costs money, slows queries, and buries the signal in noise.
Rules of thumb:
- Log entry/exit of business operations, not every method call
- Use Debug level for anything you only need during active investigation
- Avoid logging inside tight loops; aggregate and log a summary instead
- Set minimum level overrides for noisy framework namespaces
Consistent Property Naming
Agree on naming conventions across your team and enforce them:
// Inconsistent - nightmare to query
_logger.LogInformation("Processed for user {user_id}", userId);
_logger.LogInformation("Processed for user {UserId}", userId);
_logger.LogInformation("Processed for user {uid}", userId);
// Consistent - pick one convention (PascalCase recommended) and stick to it
_logger.LogInformation("Processed for user {UserId}", userId);Application Insights Integration
Request and Dependency Tracking
Application Insights provides automatic telemetry for HTTP requests, SQL calls, and external dependencies:
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = true;
});
// Custom telemetry for business metrics
public class OrderService
{
private readonly TelemetryClient _telemetry;
public OrderService(TelemetryClient telemetry)
{
_telemetry = telemetry;
}
public async Task<Order> PlaceOrderAsync(OrderRequest request)
{
var stopwatch = Stopwatch.StartNew();
try
{
var order = await ProcessOrder(request);
_telemetry.TrackEvent("OrderPlaced", new Dictionary<string, string>
{
["OrderId"] = order.Id.ToString(),
["ItemCount"] = request.Items.Count.ToString()
}, new Dictionary<string, double>
{
["OrderTotal"] = (double)order.Total,
["ProcessingTimeMs"] = stopwatch.ElapsedMilliseconds
});
return order;
}
catch (Exception ex)
{
_telemetry.TrackException(ex, new Dictionary<string, string>
{
["Operation"] = "PlaceOrder",
["UserId"] = request.UserId.ToString()
});
throw;
}
}
}Adaptive Sampling
At high traffic volumes, sending every trace to Application Insights is expensive and unnecessary. Adaptive sampling keeps costs manageable while preserving error visibility:
builder.Services.Configure<TelemetryConfiguration>(config =>
{
var processor = config.DefaultTelemetrySink.TelemetryProcessorChainBuilder;
processor.UseAdaptiveSampling(
maxTelemetryItemsPerSecond: 5,
excludedTypes: "Exception;Event");
processor.Build();
});The key insight: always exclude Exceptions and critical Events from sampling. You want 100% capture of errors regardless of traffic volume.
Distributed Tracing Setup
OpenTelemetry Integration
OpenTelemetry is the vendor-neutral standard for distributed tracing. In .NET, integrating it alongside Serilog gives you the best of both worlds:
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(
serviceName: "OrderApi",
serviceVersion: "1.0.0"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.Filter = httpContext =>
!httpContext.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.RecordException = true;
})
.AddSource("OrderApi.Activities")
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://otel-collector:4317");
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("OrderApi.Metrics")
.AddOtlpExporter());Custom Activity Spans
For business-critical operations, create custom spans to track timing and context:
public class OrderProcessor
{
private static readonly ActivitySource ActivitySource = new("OrderApi.Activities");
public async Task<Order> ProcessAsync(OrderRequest request)
{
using var activity = ActivitySource.StartActivity("ProcessOrder");
activity?.SetTag("order.item_count", request.Items.Count);
activity?.SetTag("order.user_id", request.UserId);
var inventory = await CheckInventory(request.Items);
activity?.AddEvent(new ActivityEvent("InventoryChecked"));
var payment = await ProcessPayment(request);
activity?.AddEvent(new ActivityEvent("PaymentProcessed"));
var order = await SaveOrder(request, payment);
activity?.SetTag("order.id", order.Id);
activity?.SetTag("order.total", order.Total);
return order;
}
}Connecting Logs to Traces
The real power of distributed tracing comes when your logs are linked to trace spans. Serilog can automatically include trace and span IDs:
Log.Logger = new LoggerConfiguration()
.Enrich.WithProperty("ServiceName", "OrderApi")
.Enrich.With<ActivityEnricher>() // adds TraceId and SpanId
.CreateLogger();
public class ActivityEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var activity = Activity.Current;
if (activity != null)
{
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString()));
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString()));
}
}
}Now when you find an error log, you can jump directly to the full trace in your tracing UI and see every service call that led to the failure.
Alert Strategy
SLO-Based Alerting vs Threshold Spam
In production systems I've observed teams with 200+ active alerts that everyone ignores. Alert fatigue is a real problem, and it stems from alerting on raw thresholds instead of what actually matters: your service level objectives.
Threshold-based alerting (problematic):
- CPU > 80% fires an alert (but the service is handling load fine)
- Response time > 500ms fires an alert (but it was a single outlier)
- Any 5xx fires an alert (but the error rate is 0.001%)
SLO-based alerting (effective):
- Error budget burn rate exceeds 2x in the last hour
- 99th percentile latency breaches SLO for 10 consecutive minutes
- Availability drops below 99.9% on a rolling 1-hour window
Implementing SLO Alerts
// Custom metric for SLO tracking
public class SloMetrics
{
private static readonly Meter Meter = new("OrderApi.Metrics");
private static readonly Counter<long> RequestCounter =
Meter.CreateCounter<long>("http_requests_total");
private static readonly Counter<long> ErrorCounter =
Meter.CreateCounter<long>("http_errors_total");
private static readonly Histogram<double> LatencyHistogram =
Meter.CreateHistogram<double>("http_request_duration_seconds");
public void RecordRequest(string endpoint, int statusCode, double durationSeconds)
{
var tags = new TagList
{
{ "endpoint", endpoint },
{ "status_code", statusCode.ToString() }
};
RequestCounter.Add(1, tags);
LatencyHistogram.Record(durationSeconds, tags);
if (statusCode >= 500)
{
ErrorCounter.Add(1, tags);
}
}
}Then in your alerting system (Azure Monitor, Grafana, etc.), define burn-rate alerts:
- Page (wake someone up): Error budget burn rate > 14.4x over 1 hour (consumes 2% of monthly budget in 1 hour)
- Ticket (next business day): Error budget burn rate > 3x over 6 hours
- Informational: Error budget burn rate > 1x over 3 days
This approach means you only get paged when the situation genuinely threatens your SLO, not on every transient blip.
Common Logging Mistakes
1. Logging Sensitive Data
This is the most dangerous mistake and it happens more often than people admit:
// NEVER do this
_logger.LogInformation("User login: {Email}, password: {Password}", email, password);
_logger.LogInformation("Payment processed with card {CardNumber}", cardNumber);
_logger.LogDebug("API call with token {AuthToken}", authToken);
// SAFE alternatives
_logger.LogInformation("User login: {Email}", email); // password omitted entirely
_logger.LogInformation("Payment processed with card ending {CardLast4}", cardNumber[^4..]);
_logger.LogDebug("API call authenticated for user {UserId}", userId);Add a destructuring policy to catch accidental sensitive data exposure:
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming<UserCredentials>(
creds => new { creds.Email, Password = "***REDACTED***" })
.CreateLogger();2. Missing Correlation Context
Without correlation IDs, a log line like "Payment failed" is nearly useless in a distributed system. You cannot connect it to the request that triggered it, the user who experienced it, or the upstream service that caused it.
Always propagate correlation context through HTTP headers and message queue properties.
3. Logging Inside Hot Paths
// BAD - logging per item in a 10,000-item batch
foreach (var item in items)
{
_logger.LogInformation("Processing item {ItemId}", item.Id);
await Process(item);
}
// BETTER - log the batch summary
_logger.LogInformation("Processing batch of {ItemCount} items", items.Count);
var results = await ProcessBatch(items);
_logger.LogInformation("Batch complete: {SuccessCount} succeeded, {FailCount} failed",
results.Successes, results.Failures);4. Swallowing Exceptions
// BAD - exception information is completely lost
try { await ProcessOrder(order); }
catch (Exception ex)
{
_logger.LogError("Something went wrong");
return StatusCode(500);
}
// GOOD - preserve the full exception with context
try { await ProcessOrder(order); }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order {OrderId} for user {UserId}",
order.Id, order.UserId);
return StatusCode(500);
}5. No Log Retention Policy
Logs stored forever are a compliance liability and a cost problem. Define retention tiers:
- Hot storage (last 7 days): Full-text search, fast queries
- Warm storage (7-30 days): Slower queries, reduced cost
- Cold storage (30-90 days): Archive only, compliance retention
- Deleted after retention period expires
Security and Compliance
- Never log secrets, tokens, passwords, or PII beyond what is strictly necessary
- Set retention policies aligned with your compliance requirements (GDPR, HIPAA, SOC 2)
- Use log access controls so developers can only query logs for services they own
- Audit who accesses production logs
- Encrypt logs in transit and at rest
Conclusion
High-quality observability reduces MTTR dramatically and turns incident response from guesswork into a repeatable engineering process. The investment in structured logging, distributed tracing, and SLO-based alerting pays for itself the first time you diagnose a production issue in minutes instead of hours.
Start with structured logging and correlation IDs. Add distributed tracing when you have multiple services. Build SLO-based alerts from day one. That ordering gives you the highest value at each step.
I can help design a practical observability baseline for your .NET platform.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Microservices Architecture with .NET: Design and Implementation
Design microservices architecture with .NET. Service communication, Docker, and orchestration strategies.
.NET Performance Optimization: Profiling and Best Practices
Optimize .NET application performance. Profiling tools, memory management, and async patterns.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch