SDK & Framework Guides

ASP.NET Core Error Logging: A Production Setup

Configure ASP.NET Core logging for production: exception middleware, log levels, scopes, structured output, and shipping errors somewhere you'll actually see.

When a production issue strikes, your logs are often the only record of what went wrong. But most .NET teams write logs without a strategy — too quiet to be useful, or so noisy they drown out real problems. This guide walks you through setting up ASP.NET Core error logging for production: capturing the right events at the right level, structuring them so you can search and correlate, and knowing when logs alone aren't enough.

Production logging is not optional. It's your audit trail, your forensic evidence, and the difference between fixing a bug in minutes and chasing ghosts for hours. By the end of this post, you'll have a logging pipeline that works at scale.

The built-in logging abstraction

ASP.NET Core ships with a built-in logging API centered on ILogger<T>, injected via dependency injection. This is the foundation:

using Microsoft.Extensions.Logging;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task<Order> CreateAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
        // ... business logic
        return order;
    }
}

The framework automatically provides the logger and wires up configured providers (Console, Debug, EventLog, etc.). You configure these in Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Logging is configured by default; customize it here
builder.Logging
    .ClearProviders()
    .AddConsole()
    .AddDebug()
    .AddEventLog(); // Windows Event Log

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

You can also configure per-category log levels and provider settings in appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Debug"
    },
    "Console": {
      "IncludeScopes": true,
      "TimestampFormat": "yyyy-MM-dd HH:mm:ss.fff zzz"
    }
  }
}

The Default level applies to all categories unless overridden. Microsoft.AspNetCore is set to Warning to suppress the framework's verbose request/response logging — you only see actual problems, not every GET request. Microsoft.EntityFrameworkCore is Debug so you see SQL in development, nothing in production.

Log levels done right

ASP.NET Core has six log levels, each with a purpose:

LevelUse forTypical production?
TraceLow-level diagnostic detailsNo (too noisy)
DebugDevelopment-time diagnosticsNo (unless troubleshooting)
InformationState changes, request milestonesSometimes (depends on volume)
WarningRecoverable errors, edge casesYes
ErrorFailures requiring attentionYes
CriticalSystem down, unrecoverable failureYes

In production, Warning and above is usually the right sweet spot. Information logs the lifecycle ("order created", "payment processed"), but can quickly grow to millions of lines per day on a busy system. Error and Critical are for failures.

Here's a concrete rule set:

  • Information: Order created, user logged in, payment processed, cache miss. Things that happened, business events. Don't log every loop iteration.
  • Warning: Validation failed, external API timed out, cache eviction, unusual but handled. The system recovered, but something was off.
  • Error: Database connection failed, required field missing, unhandled exception caught. An operation failed and needs investigation.
  • Critical: Service can't start, out of memory, database is down. The whole system is broken.

Set production appsettings.Production.json to Warning:

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  }
}

This cuts noise while keeping real problems visible. You can override noisier libraries (e.g., Microsoft.EntityFrameworkCore.Database.Command logs every SQL query):

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Error"
    }
  }
}

Never set production log level to Debug or Trace — the I/O cost is enormous and you'll lose logs faster than you can read them. A rule of thumb: if you wouldn't want to see 10 million lines per day, don't log at that level.

Structured logging: templates, not interpolation

This is the highest-value section. Structured logging means writing logs as machine-readable key-value pairs so you can later filter by UserId: 42 or OrderId: 1001. It requires a discipline: use message templates instead of string interpolation.

Wrong:

var userId = 42;
var orderId = 1001;
_logger.LogInformation($"Order {orderId} failed for user {userId}");

This is interpolated at log time — the fields are baked into the message string and lost forever. A log aggregator sees only "Order 1001 failed for user 42" and can't extract the number 1001 to filter by it later.

Right:

_logger.LogInformation("Order {OrderId} failed for user {UserId}", orderId, userId);

The message template "Order {OrderId} failed for user {UserId}" is the literal string; the values are passed separately. A structured logger (or Sentry SDK) captures OrderId: 1001 and UserId: 42 as searchable fields. Now you can query "all logs where OrderId = 1001" without parsing strings.

This applies everywhere:

// Good
_logger.LogWarning("Request {RequestId} exceeded timeout {TimeoutMs}ms", reqId, timeout);

// Bad
_logger.LogWarning($"Request {reqId} exceeded timeout {timeout}ms");

// Also good: simple message, no fields
_logger.LogInformation("Service started");

Name your template placeholders PascalCase and make them self-documenting: {OrderId}, not {id}. This helps future debuggers understand what the field means without reading the whole log line.

Structured logging is the reason to use a logging abstraction at all. It's also why logging alone is not enough for error tracking.

Log scopes for correlation

When a single user request touches multiple services or a background job processes a batch, you want all related logs grouped together. Log scopes attach context that follows every log statement within a unit of work:

using (var scope = _logger.BeginScope(new { RequestId = context.TraceIdentifier, UserId = userId }))
{
    _logger.LogInformation("Processing request");
    await _orderService.CreateAsync(request);  // Logs within this scope include RequestId and UserId
    _logger.LogInformation("Request complete");
}

In production, middleware can set this globally for HTTP requests:

app.Use(async (context, next) =>
{
    var traceId = context.TraceIdentifier;
    var userId = context.User?.FindFirst("sub")?.Value ?? "anonymous";

    using (var scope = _logger.BeginScope(new { TraceId = traceId, UserId = userId }))
    {
        await next.Invoke();
    }
});

app.MapControllers();

Now every log line within a request automatically includes the trace ID and user ID. When debugging, filter by TraceId: abc123 and see the exact sequence. This is also how you correlate logs across microservices.

Modern .NET logs include the trace ID in the default message template if you use ILogger.BeginScope(). Paired with a structured logging library like Serilog, scopes are the easiest way to add correlation without touching every log call.

Exception handling middleware

Unhandled exceptions in ASP.NET Core are caught by middleware and turned into HTTP 500 responses. By default, users see a generic error page; logs (if any) are silent. Set up explicit exception handling to log and respond consistently:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

// Exception handler must be early in the pipeline
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");
}
else
{
    app.UseDeveloperExceptionPage();
}

app.MapControllers();
app.MapGet("/error", (HttpContext context) =>
{
    var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
    var exception = exceptionHandlerPathFeature?.Error;

    var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
    logger.LogError(exception, "Unhandled exception in {Path}", exceptionHandlerPathFeature?.Path);

    return Results.Problem(
        detail: "An internal server error occurred.",
        statusCode: StatusCodes.Status500InternalServerError
    );
});

app.Run();

This logs the full exception stack and returns a structured ProblemDetails response. In development, UseDeveloperExceptionPage() shows the error inline. In production, it's replaced with a safe error handler that logs but doesn't leak internals.

Modern .NET also supports IExceptionHandler for more fine-grained control:

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Unhandled exception: {ExceptionMessage}", exception.Message);

        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        await context.Response.WriteAsJsonAsync(new { error = "Internal server error" }, cancellationToken);

        return true;
    }
}

Then register it in Program.cs:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler();

The middleware order matters: exception handlers must be near the top of the pipeline so they catch exceptions from everything below them.

What logging alone won't do

Logs are the first line of defense, but they have hard limits:

  • No automatic grouping: Five thousand log lines saying "database timeout" are still five thousand separate entries. You have to manually group them by pattern matching.
  • No deduplication: The same error repeated is five thousand events to triage. Error tracking systems automatically group identical errors into a single issue.
  • No alerting: A log file no one reads is not monitoring. You have to build alerts on top of logs, or use a log aggregation service.
  • No correlation across services: In a microservices setup, the same logical failure appears as separate log entries in three databases.
  • Storage and cost: Logging at scale costs money (storage, ingestion, retention). A log-only strategy means expensive infrastructure or aggressive retention limits.

This is where error tracking differs from logging. An error tracking service like LightTrace captures exceptions, groups identical errors, alerts your team, and provides a dashboard to triage and resolve issues. You still use logs for diagnostics, but error tracking is how you actually monitor production.

Wiring error tracking to your logs

Once you have logging in place, add the Sentry SDK to turn exceptions into actionable issues:

dotnet add package Sentry.AspNetCore

Then initialize it in Program.cs:

using Sentry.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.UseSentry(options =>
{
    options.Dsn = "https://<key>@light-trace.robomiri.com/1";
    options.Environment = builder.Environment.EnvironmentName;
    options.Release = Environment.GetEnvironmentVariable("APP_VERSION") ?? "dev";
});

builder.Services.AddControllers();
var app = builder.Build();

app.MapControllers();
app.Run();

Now every unhandled exception reports to LightTrace with full stack trace, request context, and your structured log fields attached. See the SDK setup guide for deep integration.

Never log secrets, PII, or connection strings — they'll end up in your logs (and error tracking). Use sensitive data scrubbing to redact credentials before they hit any system.

In production, logs + error tracking

Start with the logging setup above. Set your default level to Warning, use message templates, add scopes for correlation, and catch exceptions explicitly. This gives you the hygiene to run production safely.

Then add error tracking to turn exceptions into grouped, actionable issues. Logs tell you what happened; error tracking tells you that something broke and which users were affected.

Start tracking errors in minutes

Set up LightTrace alongside your ASP.NET Core logs to catch exceptions as they happen — group them, alert your team, and resolve regressions fast.

With structured logging and error tracking in place, your team stops firefighting in the dark. Every production issue is visible, searchable, and resolved before customers complain.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.