.NET Core

Logging and Monitoring in .NET: Serilog and Application Insights

14 min readFebruary 9, 2026Updated Mar 9, 2026
.NET loggingSerilog .NETStructured logging C#Application Insights.NET monitoringILogger .NETLog correlation.NET observability

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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
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:

csharp
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.
csharp
// 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:

csharp
// 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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

csharp
// 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:

csharp
// 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:

csharp
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

csharp
// 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

csharp
// 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

  1. Never log secrets, tokens, passwords, or PII beyond what is strictly necessary
  2. Set retention policies aligned with your compliance requirements (GDPR, HIPAA, SOC 2)
  3. Use log access controls so developers can only query logs for services they own
  4. Audit who accesses production logs
  5. 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

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch