Skip to main content
Published / updated

Error Handling and Logging

Before you start

You need: data access (Article 09).

Time: about 50 minutes, plus the practice.

Learning objective

Return a safe, consistent error response for every failure, and write logs that let someone diagnose a production problem from a user's description.

Topics

  • Global exception handling
  • ProblemDetails
  • Mapping domain exceptions to status codes
  • What must never reach the client
  • Structured logging
  • Log levels
  • Scopes and correlation ids
  • What to log and what not to
  • Health checks

Global exception handling

Handling exceptions per action produces repetition and one action that forgets. Handle them once.

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

public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger, IHostEnvironment environment)
{
_logger = logger;
_environment = environment;
}

public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
var (status, title) = exception switch
{
DuplicateRollNumberException => (StatusCodes.Status409Conflict, "Duplicate roll number"),
NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed"),
ForbiddenException => (StatusCodes.Status403Forbidden, "Forbidden"),
ConcurrencyException => (StatusCodes.Status409Conflict, "Concurrency conflict"),
OperationCanceledException => (StatusCodes.Status499ClientClosedRequest, "Cancelled"),
_ => (StatusCodes.Status500InternalServerError, "An error occurred")
};

if (status >= 500)
{
_logger.LogError(exception,
"Unhandled exception on {Method} {Path}",
context.Request.Method, context.Request.Path);
}
else
{
_logger.LogWarning(exception,
"Handled {ExceptionType} on {Method} {Path}",
exception.GetType().Name, context.Request.Method, context.Request.Path);
}

var problem = new ProblemDetails
{
Status = status,
Title = title,
Instance = context.Request.Path,
Detail = status >= 500 && !_environment.IsDevelopment()
? "An unexpected error occurred. Please try again."
: exception.Message
};

problem.Extensions["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier;

context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(problem, ct);

return true;
}
}
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();

UseExceptionHandler must be registered early, because it only catches exceptions from middleware registered after it. Placing it after MapControllers means it catches nothing.

The Detail line is the important one: a 500 in production returns a generic message, while a 4xx returns the domain exception's message — which is safe, because you wrote it.

What must never reach the client

// Never in production
app.UseDeveloperExceptionPage();

A stack trace tells an attacker your framework version, your namespace structure, your file paths, and often your ORM and database. A SqlException message can name tables and columns.

if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler();
app.UseHsts();
}
Never returnReturn instead
Stack tracesA generic message plus a traceId
SqlException messages"Could not save. Please try again."
Connection stringsNothing
Inner exception chainsThe traceId
File pathsNothing

The traceId is what makes a safe message useful. The user quotes it in a support ticket, and someone finds the exact request in the logs with the full detail. Without it, "an error occurred" is unactionable for everyone.

Mapping domain exceptions

public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}

public class ForbiddenException : Exception
{
public ForbiddenException(string message) : base(message) { }
}

public class DuplicateRollNumberException : Exception
{
public string RollNumber { get; }

public DuplicateRollNumberException(string rollNumber, Exception? inner = null)
: base($"Roll number '{rollNumber}' is already in use.", inner)
{
RollNumber = rollNumber;
}
}

The service throws domain exceptions; the handler maps them to status codes. The controller stays free of try/catch:

[HttpPost]
public async Task<ActionResult<StudentDto>> Create(
StudentCreateRequest request, CancellationToken ct)
{
var created = await _studentService.CreateAsync(User.GetSchoolId(), request, ct);

return CreatedAtAction(nameof(GetById), new { publicId = created.PublicId }, created);
}

For a field-level error the controller does still catch, because the response shape differs:

catch (DuplicateRollNumberException ex)
{
ModelState.AddModelError(nameof(request.RollNumber), ex.Message);
return ValidationProblem(ModelState);
}

Exceptions are for exceptional cases. "Student not found" on a lookup is an expected outcome — return null and let the controller decide 404. Throwing for every miss is expensive and makes logs noisy.

Structured logging

// Structured — the values are separate, searchable fields
_logger.LogInformation(
"Student {RollNumber} created for school {SchoolId} by {User}",
student.RollNumber, schoolId, performedBy);

// String interpolation — one opaque string, nothing is searchable
_logger.LogInformation($"Student {student.RollNumber} created for school {schoolId}");

Use the message template, not interpolation. With a structured sink, RollNumber and SchoolId become queryable fields — "every log entry for school 7 in the last hour" is one query. With interpolation it is a text search that misses anything formatted differently.

Interpolation also formats the string even when the level is disabled, so a LogDebug in a hot path costs work in production for output nobody sees.

Log levels

LevelUseProduction
TraceVery detailed diagnosticsOff
DebugDevelopment diagnosticsOff
InformationNormal significant eventsOn
WarningSomething unexpected, handledOn
ErrorAn operation failedOn
CriticalThe application cannot continueOn
_logger.LogDebug("Searching students for school {SchoolId} with term {Term}", schoolId, term);
_logger.LogInformation("Payment {PaymentId} of {Amount} recorded for {RollNumber}",
paymentId, amount, rollNumber);
_logger.LogWarning("Duplicate roll number {RollNumber} rejected for school {SchoolId}",
rollNumber, schoolId);
_logger.LogError(ex, "Failed to record payment for account {FeeAccountId}", feeAccountId);
_logger.LogCritical(ex, "Database is unreachable");
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"NexCoding.SchoolPortal": "Information"
}
}
}

Microsoft.AspNetCore at Information logs every request twice. Setting it to Warning removes most of the noise while keeping genuine framework problems.

Information for business events, Warning for handled problems, Error for failures. Logging every method entry at Information produces a volume nobody reads — and cost, on a hosted log service.

Scopes and correlation ids

app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Activity.Current?.Id
?? context.TraceIdentifier;

using (logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = correlationId,
["Path"] = context.Request.Path.Value ?? string.Empty
}))
{
context.Response.Headers.Append("X-Correlation-Id", correlationId);
await next(context);
}
});

Every log entry written during that request carries the correlation id. That is what turns a log file into something searchable — one id retrieves every entry for one user's failed action, across every layer.

Returning it in the response header means the client can show it, and the user can quote it.

Adding the user and tenant is worth doing, after authentication:

if (context.User.Identity?.IsAuthenticated == true)
{
using (logger.BeginScope(new Dictionary<string, object>
{
["UserId"] = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown",
["SchoolId"] = context.User.FindFirst("SchoolId")?.Value ?? "unknown"
}))
{
await next(context);
}
}

What to log, and what not to

Log: business events (a payment recorded, a student enrolled), handled failures with their reason, external calls with duration and outcome, authentication failures, authorisation denials, slow operations, and startup configuration.

Never log:

// All of these are a data breach in a log file
_logger.LogInformation("Login attempt for {Email} with {Password}", email, password);
_logger.LogDebug("Token: {Token}", token);
_logger.LogInformation("Connecting with {ConnectionString}", connectionString);
_logger.LogInformation("Student {@Student}", student); // includes every field
Never logWhy
PasswordsEven a failed attempt often contains a real password
Tokens and API keysUsable directly by anyone with log access
Connection stringsContains credentials
Full personal recordsParent phone numbers, addresses — a data-protection issue
Card or bank detailsCompliance

Logs are copied to aggregation services, retained for months, and readable by more people than the database. Log identifiers, not payloads:

_logger.LogInformation("Payment {PaymentId} recorded for student {StudentId}",
paymentId, studentId);

Anyone diagnosing an incident can look up the record; the log itself carries nothing sensitive.

Mask when a value must appear:

var safe = new SqlConnectionStringBuilder(connectionString) { Password = "***" };
_logger.LogInformation("Connecting with {ConnectionString}", safe.ConnectionString);

Request logging

app.UseHttpLogging();
builder.Services.AddHttpLogging(options =>
{
options.LoggingFields = HttpLoggingFields.RequestPath
| HttpLoggingFields.RequestMethod
| HttpLoggingFields.ResponseStatusCode
| HttpLoggingFields.Duration;

options.RequestHeaders.Remove("Authorization");
options.RequestHeaders.Remove("Cookie");
});

Removing Authorization and Cookie is not optional. The defaults exclude them, and a well-meaning "log all headers" change reintroduces them — putting bearer tokens into every log line.

Never enable request-body logging in production. Bodies contain personal data, and on a busy API the volume is unmanageable.

Custom timing gives more control:

app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();

try
{
await next(context);
}
finally
{
stopwatch.Stop();

var level = stopwatch.ElapsedMilliseconds > 1000 ? LogLevel.Warning : LogLevel.Information;

logger.Log(level,
"{Method} {Path} responded {StatusCode} in {Elapsed}ms",
context.Request.Method, context.Request.Path,
context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
}
});

Escalating slow requests to Warning means they surface in an alert rather than being buried among successes.

Structured sinks

Right-click the project → Manage NuGet Packages → Browse, search for the package, and click Install.

The Package Manager Console (Tools → NuGet Package Manager → Package Manager Console) does the same thing typed:

Install-Package Serilog.AspNetCore
Install-Package Serilog.Sinks.Seq
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console()
.WriteTo.Seq(context.Configuration["Seq:Url"]!));

The console sink is fine in development and useless in production, where logs must be searchable, retained and alertable. Seq, Elasticsearch, Application Insights and CloudWatch all accept structured events.

Enrich.FromLogContext() is what makes BeginScope properties flow into the structured output.

Health checks

builder.Services.AddHealthChecks()
.AddSqlServer(
builder.Configuration.GetConnectionString("SchoolDb")!,
name: "sql",
tags: new[] { "ready" })
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" });
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
EndpointQuestionUsed by
/health/liveIs the process running?Container restart policy
/health/readyCan it serve traffic?Load balancer

The distinction matters. A liveness check that also tests the database restarts the container every time the database blips — which does not fix anything and takes the application down. Liveness must check only the process; readiness checks dependencies.

Do not expose dependency detail publicly:

app.MapHealthChecks("/health/ready", new HealthCheckOptions { /* ... */ })
.RequireAuthorization("Monitoring");

A public readiness endpoint naming your database server is reconnaissance.

Errors you will hit

What you seeCauseFix
Stack trace shown to the callerDeveloper exception page on in productionOnly in Development
A wrong number instead of an errorcatch (Exception) returning a defaultLet it surface
Logs cannot be searched by fieldInterpolated strings instead of structured loggingUse named placeholders
Cannot trace one user's failureNo correlation idAdd one and log it in a scope
A secret appears in the logsLogged a whole request or connection stringLog identifiers only
The exception handler never runsRegistered after other middlewareIt must be first

A 500 is better than a wrong answer. A crash gets investigated; a plausible wrong number gets acted on.

Common mistakes

  • UseDeveloperExceptionPage in production
  • UseExceptionHandler registered too late
  • Returning exception messages from 500 responses
  • No traceId, so a safe message is unactionable
  • try/catch in every action instead of one handler
  • Exceptions for expected outcomes such as "not found"
  • String interpolation in log messages
  • Logging passwords, tokens or connection strings
  • Logging whole entities with {@Object}
  • Logging every method entry at Information
  • Framework logs left at Information, drowning your own
  • No correlation id
  • Enabling request-body logging in production
  • A liveness check that tests the database
  • A public readiness endpoint exposing dependencies

Practice

The course exercise is trace a 400/401/500 response.

  1. Implement GlobalExceptionHandler with the exception-to-status mapping. Register it and UseExceptionHandler.
  2. Throw an unhandled exception. Confirm a generic 500 with a traceId and no stack trace.
  3. Run in Development and confirm the message now includes detail.
  4. Move UseExceptionHandler to after MapControllers. Throw again and record what the client receives.
  5. Throw NotFoundException and confirm 404 with ProblemDetails.
  6. Throw DuplicateRollNumberException and confirm 409.
  7. Add traceId to the response, then find the matching log entry.
  8. Replace one structured log call with string interpolation. Query your sink for SchoolId=7 and confirm the interpolated entry is not found.
  9. Add correlation-id middleware with BeginScope. Confirm every entry for one request carries it.
  10. Log a connection string unmasked, find it in the log, then fix it with SqlConnectionStringBuilder.
  11. Set Microsoft.AspNetCore to Information and count the entries for one request. Set it to Warning and compare.
  12. Add slow-request warning logging. Add await Task.Delay(1500) to an action and confirm the Warning.
  13. Add liveness and readiness health checks. Stop SQL Server and confirm readiness fails while liveness passes.
  14. Make readiness test the database in the liveness check too, and reason through what a container orchestrator would do.

Exercises 2, 8 and 10 are the three that matter most — an information leak, an unsearchable log, and a credential in a log file.

You can now

  • Return safe, consistent errors for every failure
  • Keep the developer exception page out of production
  • Write structured logs with named placeholders
  • Add a correlation id and trace one request end to end
  • Say what must never be logged

Review questions

  1. Why must UseExceptionHandler be registered early?
  2. What makes a generic error message useful rather than useless?
  3. Why is a message template better than string interpolation in a log call?
  4. Why must a liveness check not test the database?

Next: Security, CORS and JWT