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 return | Return instead |
|---|---|
| Stack traces | A generic message plus a traceId |
SqlException messages | "Could not save. Please try again." |
| Connection strings | Nothing |
| Inner exception chains | The traceId |
| File paths | Nothing |
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
| Level | Use | Production |
|---|---|---|
Trace | Very detailed diagnostics | Off |
Debug | Development diagnostics | Off |
Information | Normal significant events | On |
Warning | Something unexpected, handled | On |
Error | An operation failed | On |
Critical | The application cannot continue | On |
_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 log | Why |
|---|---|
| Passwords | Even a failed attempt often contains a real password |
| Tokens and API keys | Usable directly by anyone with log access |
| Connection strings | Contains credentials |
| Full personal records | Parent phone numbers, addresses — a data-protection issue |
| Card or bank details | Compliance |
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
});
| Endpoint | Question | Used by |
|---|---|---|
/health/live | Is the process running? | Container restart policy |
/health/ready | Can 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 see | Cause | Fix |
|---|---|---|
| Stack trace shown to the caller | Developer exception page on in production | Only in Development |
| A wrong number instead of an error | catch (Exception) returning a default | Let it surface |
| Logs cannot be searched by field | Interpolated strings instead of structured logging | Use named placeholders |
| Cannot trace one user's failure | No correlation id | Add one and log it in a scope |
| A secret appears in the logs | Logged a whole request or connection string | Log identifiers only |
| The exception handler never runs | Registered after other middleware | It must be first |
A 500 is better than a wrong answer. A crash gets investigated; a plausible wrong number gets acted on.
Common mistakes
UseDeveloperExceptionPagein productionUseExceptionHandlerregistered too late- Returning exception messages from 500 responses
- No
traceId, so a safe message is unactionable try/catchin 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.
- Implement
GlobalExceptionHandlerwith the exception-to-status mapping. Register it andUseExceptionHandler. - Throw an unhandled exception. Confirm a generic 500 with a
traceIdand no stack trace. - Run in Development and confirm the message now includes detail.
- Move
UseExceptionHandlerto afterMapControllers. Throw again and record what the client receives. - Throw
NotFoundExceptionand confirm 404 withProblemDetails. - Throw
DuplicateRollNumberExceptionand confirm 409. - Add
traceIdto the response, then find the matching log entry. - Replace one structured log call with string interpolation. Query your sink for
SchoolId=7and confirm the interpolated entry is not found. - Add correlation-id middleware with
BeginScope. Confirm every entry for one request carries it. - Log a connection string unmasked, find it in the log, then fix it with
SqlConnectionStringBuilder. - Set
Microsoft.AspNetCoretoInformationand count the entries for one request. Set it toWarningand compare. - Add slow-request warning logging. Add
await Task.Delay(1500)to an action and confirm theWarning. - Add liveness and readiness health checks. Stop SQL Server and confirm readiness fails while liveness passes.
- 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
- Why must
UseExceptionHandlerbe registered early? - What makes a generic error message useful rather than useless?
- Why is a message template better than string interpolation in a log call?
- Why must a liveness check not test the database?
Next: Security, CORS and JWT