Backend Development with .NET
Session 23
Logging, Monitoring & Health Checks
Eng. Seif Mansour  ·  Andalusia Academy
Week 9  ·  2 hours
Session Goals

By the end of this session, you will be able to:

  • Set up structured logging with Serilog, writing to a console sink and a rolling file sink
  • Write log statements as message templates rather than interpolated strings
  • Choose the correct log level for an event, and configure minimum levels per namespace
  • Add a correlation ID middleware and trace one request across every log entry it produced
  • Expose /health, /health/live, and /health/ready endpoints with database and dependency checks
Agenda
Time Segment Type Duration
0:00Why logging matters in productionDiscussion10 min
0:10Serilog setup & structured logsDemo30 min
0:40Log levels — when to use eachTheory15 min
0:55Correlation IDsDemo15 min
1:10Health check endpointsDemo20 min
1:30Lab — add logging to the Task APILab25 min
1:55Wrap-upDiscussion5 min
Why Logging Matters in Production
On your machine you have a debugger. In production, logs are the only debugger you get.
In this section
Debugging a system you cannot attach to
Console.WriteLine is not logging
Logs, metrics and traces
What a useful log entry answers
What must never be logged
The Production Reality
  • You cannot attach a debugger, set a breakpoint, or reproduce the exact request
  • The bug happened three hours ago, for one user, and has not happened since
  • Twenty requests were in flight at the same time — their output is interleaved
  • The only artefact that survives the request is what you wrote to the log
  • A support ticket says "it failed" — your job is to turn that into a stack trace and a user id
The design rule
Write the log statement you will wish you had written, at the moment you write the code — not after the incident.
Console.WriteLine Is Not Logging
  • No level — you cannot turn it down in production or up during an incident
  • No timestamp, no thread, no request context
  • No destination other than stdout — nothing to search tomorrow
  • Not structured — every value is melted into one string
  • Cannot be filtered per namespace, unlike ILogger
// What you wrote at 2am
Console.WriteLine("saving task " + id);

// What the log file shows
// saving task 41

// What you actually needed
// 2026-08-01 14:22:07.113 +03:00 [INF]
//   Task 41 updated by user 88 (CorrelationId: 9f2c...)
Note
Console.WriteLine also writes synchronously and blocks the request thread. Serilog's console sink can batch and write asynchronously.
Three Pillars of Observability
Logs
Discrete events with full detail. Answer what exactly happened to this one request.
Serilog, Seq, Elasticsearch
Metrics
Numbers aggregated over time. Answer is the system healthy right now.
Prometheus, App Insights
Traces
One request followed across services. Answer which hop was slow.
OpenTelemetry, Jaeger
Where this session sits
We build the logs pillar properly, add a correlation ID as a first step toward traces, and expose health endpoints so a monitoring system can collect the simplest metric of all: is the service up.
What a Good Log Entry Answers
QuestionComes from
When did it happen?Timestamp — added by the logger, never by you
How serious is it?Log level: Debug, Information, Warning, Error, Fatal
What happened?The message template — a stable, searchable sentence
To which entity?Named properties: {TaskId}, {UserId}
In which request?Correlation ID pushed onto the log context
Where in the code?Source context — the class the ILogger<T> belongs to
What Must Never Reach the Log
Passwords, even hashed — and never the raw login request bodyNever
JWTs, refresh tokens, API keys, connection stringsNever
Full credit-card numbers, national IDs, health dataNever
User id instead of email, entity id instead of the entityPrefer
Correlation id, endpoint, status code, elapsed millisecondsPrefer
A real incident pattern
A team logs the whole request body on validation failure "to make debugging easier". Six weeks later the log store contains every mistyped password on the login endpoint — in plain text, replicated to three environments and a backup.
Serilog Setup
Replacing the default provider with a configurable pipeline of enrichers and sinks
In this section
ILogger stays — only the provider changes
Installing the packages
Configuring the logger in Program.cs
Sinks: console, rolling file, and beyond
Request logging and enrichers
Configuration from appsettings.json
You Keep ILogger — Serilog Is the Provider
  • ASP.NET Core defines the ILogger<T> abstraction; providers decide where entries go
  • Serilog plugs in as a provider — your controllers and services do not change at all
  • Inject ILogger<TaskService> and the class name becomes the SourceContext property
  • The static Log.Logger exists for startup code that runs before DI is ready
public class TaskService
{
    private readonly ILogger<TaskService> _logger;

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

    public async Task<TaskItem> CreateAsync(CreateTaskDto dto, int userId)
    {
        _logger.LogInformation(
            "Creating task {Title} for user {UserId}",
            dto.Title, userId);
        // ...
    }
}
Installing Serilog
terminal — project root
cd TaskApi
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File

# Optional but useful for the lab
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread

dotnet build
Serilog.AspNetCore already brings in the core library and the hosting integration — the two sink packages are what actually decide where entries are written.
Configuring the Logger
Program.cs
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
    .Enrich.FromLogContext()
    .WriteTo.Console(outputTemplate:
        "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties}{NewLine}{Exception}")
    .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
    .CreateLogger();

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog();
Read it as a pipeline: minimum level filters, enrichers add properties, sinks write the result. Every call returns the builder, so order inside each group does not matter.
That Configuration, Line by Line
CallEffect
MinimumLevel.Debug()Global floor — anything below Debug is discarded before a sink sees it
MinimumLevel.Override("Microsoft", ...)Silences framework noise: only Warning and above from Microsoft.*
Enrich.FromLogContext()Required for correlation IDs — lets LogContext.PushProperty attach values
WriteTo.Console(outputTemplate: ...)Human-readable output for the developer terminal
WriteTo.File("logs/app-.log", ...)The dash before .log is where the date is inserted
builder.Host.UseSerilog()Replaces every default provider with Serilog
Reading the Output Template
  • {Timestamp:HH:mm:ss} — any .NET date format string works
  • {Level:u3} — three-letter uppercase level: INF, WRN, ERR
  • {Message:lj}literal text, jSON-formatted embedded values
  • {Properties} — every property not already in the message
  • {Exception} — the full stack trace, when one was passed
[14:22:07 INF] Creating task "Write report"
  for user 88 {CorrelationId="9f2c31de",
  SourceContext="TaskApi.Services.TaskService"}

[14:22:07 INF] HTTP POST /api/tasks responded
  201 in 42.7188 ms {CorrelationId="9f2c31de"}

[14:22:44 ERR] Failed to delete task 41
System.InvalidOperationException: Task is locked
   at TaskApi.Services.TaskService.DeleteAsync()
Sinks — Where Entries Go
SinkPackageUse it for
ConsoleSerilog.Sinks.ConsoleLocal development; container stdout in production
FileSerilog.Sinks.FileRolling files on a VM or single server
SeqSerilog.Sinks.SeqSearching structured logs by property — ideal in class
ElasticsearchSerilog.Sinks.ElasticsearchLarge-scale central log search
Application InsightsSerilog.Sinks.ApplicationInsightsAzure-hosted APIs
A sink is just a destination
You may register as many as you like. Each one can have its own minimum level, so Console can show Debug while the file keeps only Information and above.
Rolling Files Without Filling the Disk
Program.cs
.WriteTo.File(
    path: "logs/app-.log",
    rollingInterval: RollingInterval.Day,
    retainedFileCountLimit: 14,
    fileSizeLimitBytes: 20 * 1024 * 1024,
    rollOnFileSizeLimit: true,
    outputTemplate:
        "{Timestamp:o} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}")

// Produces: logs/app-20260801.log, logs/app-20260802.log, ...
// Keeps the newest 14 files, each capped at 20 MB
Without retainedFileCountLimit the log directory grows until the disk is full — and a full disk takes the API down as surely as a bug does.
One Line Per Request
Program.cs
app.UseSerilogRequestLogging(options =>
{
    options.MessageTemplate =
        "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";

    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
        diagnosticContext.Set("UserId",
            httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
    };
});
This replaces the framework's four noisy entries per request with a single summary line — and gives you response time as a searchable numeric property.
Where Logging Sits in the Pipeline

Highlighted stages are added in this session.

app.UseSerilogRequestLogging()First, so it times everything below
app.UseMiddleware<CorrelationIdMiddleware>()Before anything that logs
app.UseExceptionHandler()Session 10 — logs and shapes the error
app.UseHttpsRedirection()Session 21
app.UseRouting()Selects the endpoint
app.UseAuthentication()Session 19
app.UseAuthorization()Session 20
app.MapHealthChecks("/health")Endpoint, not middleware
Order rule
The correlation ID must be pushed before the first component that writes a log entry — otherwise the earliest and most interesting entries have no correlation ID.
Configuring Serilog from appsettings.json
appsettings.json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "File",
        "Args": {
          "path": "logs/app-.log",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 14
        }
      }
    ],
    "Enrich": [ "FromLogContext", "WithMachineName" ]
  }
}
Bind it with .ReadFrom.Configuration(builder.Configuration) — now log level is an operations decision, changeable without a rebuild.
Two-Stage Initialisation
Program.cs
// Stage 1 — bootstrap logger, active before configuration is read
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    // Stage 2 — the real logger, built from appsettings.json
    builder.Host.UseSerilog((context, services, configuration) => configuration
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

    var app = builder.Build();
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly during startup");
}
finally
{
    Log.CloseAndFlush();
}
Log.CloseAndFlush() guarantees buffered entries reach the sinks before the process exits — without it, the crash that killed the app is the entry you lose.
Enrichers — Properties on Every Entry
  • An enricher adds a property to every event, so you never repeat it in message templates
  • FromLogContext is the important one — it is how correlation IDs travel
  • Machine name and environment separate entries when several instances share one log store
  • WithProperty pins a constant such as the service name
new LoggerConfiguration()
    .Enrich.FromLogContext()
    .Enrich.WithMachineName()
    .Enrich.WithEnvironmentName()
    .Enrich.WithProperty("Service", "TaskApi")
    .WriteTo.Console()
    .CreateLogger();

// Every entry now carries:
//   MachineName   = "web-prod-02"
//   EnvironmentName = "Production"
//   Service       = "TaskApi"
Structured Logging
The difference between a log you read and a log you can query
In this section
Message templates vs string interpolation
The event pipeline, end to end
What the JSON actually looks like
Destructuring with @ and $
Scopes with BeginScope
Querying by property
Interpolation Destroys Structure
Services/TaskService.cs
// Bad — string interpolation loses structure
_logger.LogInformation($"Task {taskId} created by user {userId}");

// Good — structured, searchable by TaskId and UserId
_logger.LogInformation("Task {TaskId} created by user {UserId}", taskId, userId);
What you lose with the first form
The event becomes a unique string. You cannot filter by TaskId = 41, you cannot count how often the event fired, and you cannot group identical events — because to the log store, every one of them is different text.
Two rules
Property names in the template are PascalCase, and the template string is a constant — never a variable, never interpolated.
The Structured Logging Pipeline
1 · Call site _logger.LogInformation( "Task {TaskId} created", taskId); 2 · LogEvent Template kept intact Values bound as named properties, not text 3 · Enrichers + CorrelationId + SourceContext + MachineName 4 · Level filter MinimumLevel and per-namespace overrides drop the rest 5 · Sinks — one event, many destinations Console Rendered with the output template Rolling file logs/app-20260801.log retained 14 days Seq / Elasticsearch JSON, queried by property name Your own sink Alerting, metrics, anything you write
The template survives all the way to the sink — that is what makes the entry queryable rather than merely readable.
What the Sink Actually Stores
Seq / Elasticsearch document
{
  "@t": "2026-08-01T14:22:07.1134821Z",
  "@mt": "Task {TaskId} created by user {UserId}",
  "@l": "Information",
  "TaskId": 41,
  "UserId": 88,
  "CorrelationId": "9f2c31de-5b0a-4a1e-9d33-8c7f0f6ad2b1",
  "SourceContext": "TaskApi.Services.TaskService",
  "MachineName": "web-prod-02",
  "EnvironmentName": "Production"
}
@mt is the message template, stored separately from the values. Every occurrence of this event shares the same @mt, so the log store can group and count them.
Destructuring — @ and $
  • By default Serilog calls ToString() on complex objects — usually the type name, which is useless
  • {@Object} destructures: the object is captured property by property
  • {$Object} forces the plain string form
  • Destructure DTOs, never entities — an entity drags navigation properties and secrets into the log
// Type name only — not useful
_logger.LogDebug("Received {Dto}", dto);

// Full structure captured as JSON
_logger.LogDebug("Received {@Dto}", dto);
// -> { "Title": "Write report", "DueDate": ... }

// Force the string form
_logger.LogDebug("Received {$Dto}", dto);

// Never destructure an entity
_logger.LogDebug("Loaded {@User}", user);
// -> leaks PasswordHash and RefreshToken
Logging Exceptions Correctly
Services/TaskService.cs
try
{
    await _repository.DeleteAsync(id, cancellationToken);
    _logger.LogInformation("Task {TaskId} deleted by user {UserId}", id, userId);
}
catch (DbUpdateConcurrencyException ex)
{
    // The exception goes in the FIRST parameter, not into the message
    _logger.LogWarning(ex,
        "Concurrency conflict deleting task {TaskId}; retrying", id);
    throw;
}
catch (Exception ex)
{
    _logger.LogError(ex,
        "Unexpected failure deleting task {TaskId} for user {UserId}", id, userId);
    throw;
}
LogError(ex, "...") stores the stack trace as a first-class field. LogError("... " + ex.Message) throws the stack trace away and gives you a one-line clue instead of a diagnosis.
Scopes — Properties for a Block of Work
  • A scope attaches properties to every entry written inside a using block
  • Perfect for batch jobs: tag every entry with the batch id without repeating it
  • ILogger.BeginScope is the framework API; LogContext.PushProperty is Serilog's
  • Both require Enrich.FromLogContext() to appear in the output
using (_logger.BeginScope(
    new Dictionary<string, object>
    {
        ["ImportBatchId"] = batchId,
        ["SourceFile"] = fileName
    }))
{
    foreach (var row in rows)
    {
        _logger.LogDebug("Importing row {RowNumber}", row.Number);
    }
}
// Every entry inside carries ImportBatchId
// and SourceFile automatically
The Payoff — Querying by Property

In Seq, every property you logged is a filterable field:

UserId = 88 and @Level = 'Error'Who broke
CorrelationId = '9f2c31de-5b0a-4a1e-9d33-8c7f0f6ad2b1'One request
Elapsed > 1000 and RequestPath like '/api/tasks%'Slow calls
StatusCode = 429Rate limited
Full-text search across interpolated stringsGuesswork
Run Seq locally in one command
docker run -d --name seq -e ACCEPT_EULA=Y -p 5341:80 datalust/seq — then add .WriteTo.Seq("http://localhost:5341").
Log Levels
A level is a promise to whoever reads the log at 3am about how much they should care
In this section
The six levels and their meaning
Concrete examples from the Task API
Minimum level and overrides
Different levels per environment
The cost of over-logging
The Level Hierarchy
Trace Debug Information Warning Error Fatal Development floor — Debug Production floor — Information verbose, cheap to ignore rare, always investigated increasing severity
Setting a minimum level discards everything to the left of the line before a sink is ever touched.
When to Use Each Level
LevelWhen to use
TraceExtremely verbose, not used in production
DebugDeveloper diagnostics
InformationNormal application flow (request received, task created)
WarningRecoverable unexpected situations (retry attempt, deprecated endpoint called)
ErrorFailures that are handled but should be investigated
FatalApplication cannot continue
Naming note
Serilog says Fatal; the Microsoft ILogger abstraction says Critical. They are the same level — Serilog maps between them automatically.
The Same Six Levels, in the Task API
Trace
The exact SQL parameter values EF Core sent — enabled for minutes, never left on
Debug
"Filter parsed as status=Open, sort=DueDate desc" while chasing a pagination bug
Information
"Task {TaskId} created by user {UserId}" — the audit trail of normal business events
Warning
A caller hit the rate limiter; a deprecated v1 endpoint was used; a retry succeeded on attempt two
Error
The database rejected an update; an external service returned 500 — the request failed, the API did not
Fatal
The connection string is invalid at startup — the process is about to exit
Three Ways Levels Go Wrong
Everything is Error
A user submitting an invalid form is not an error — it is expected input the API handled correctly. Log it at Information, or not at all. If every failed validation raises an alert, the team stops reading alerts.
Everything is Information
"Entering method", "leaving method", "loop iteration 4211". At a thousand requests per minute this buries the six entries that mattered and multiplies your log bill.
The test that works
Ask: would I want to be woken up for this? Yes means Error or Fatal. Would I want to see it while reconstructing what a user did? Information. Only useful while actively debugging? Debug.
Turning Down the Noise per Namespace
Program.cs
new LoggerConfiguration()
    .MinimumLevel.Information()

    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
    .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Error)
    .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)

    // Your own code stays verbose while you work on it
    .MinimumLevel.Override("TaskApi.Services", LogEventLevel.Debug)

    .Enrich.FromLogContext()
    .WriteTo.Console()
    .CreateLogger();
Overrides match on the SourceContext prefix, so the longest matching namespace wins — exactly like configuration keys.
Different Levels per Environment
EnvironmentMinimum levelSinksWhy
DevelopmentDebugConsoleYou are reading it live; volume does not matter
StagingDebug or InformationConsole + FileReproducing production bugs with more detail
ProductionInformationFile + SeqSignal over noise; storage and ingest cost money
Where this lives
Put the Serilog section in appsettings.Development.json and appsettings.Production.json. Session 24 covers how those files are layered and how secrets stay out of them.
Correlation IDs
Twenty requests are interleaved in the log. A correlation ID is how you pull one of them back out.
In this section
The interleaving problem
The middleware, line by line
What the output looks like
Returning the id to the client
Propagating to downstream services
Whose Log Line Is This?
  • ASP.NET Core handles requests concurrently — entries from different requests interleave
  • Timestamps do not help: three requests can start in the same millisecond
  • Without a shared id you cannot tell which "task not found" belongs to which caller
  • One id, generated once per request, pushed onto the log context, solves it
[14:22:07 INF] Fetching task 41
[14:22:07 INF] Fetching task 77
[14:22:07 WRN] Task not found
[14:22:07 INF] Task returned in 12 ms
[14:22:07 ERR] Delete failed

--- with a correlation id ---

[14:22:07 INF] Fetching task 41 {Cid="9f2c"}
[14:22:07 INF] Fetching task 77 {Cid="4b81"}
[14:22:07 WRN] Task not found   {Cid="4b81"}
[14:22:07 INF] Returned in 12ms {Cid="9f2c"}
[14:22:07 ERR] Delete failed    {Cid="4b81"}
The Correlation ID Middleware
Program.cs
app.Use(async (context, next) =>
{
    var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
        ?? Guid.NewGuid().ToString();

    context.Response.Headers.Append("X-Correlation-Id", correlationId);

    using (LogContext.PushProperty("CorrelationId", correlationId))
    {
        await next();
    }
});
Twelve lines. Reuse the caller's id if there is one, otherwise mint a new one; echo it back on the response; push it onto the log context for the lifetime of the request.
Why Each Line Is There
  • Reuse the incoming header first. If a gateway or another service already assigned an id, keeping it links the two systems' logs together
  • Fall back to a new GUID. Every request gets an id, even a direct call from Postman
  • Echo it on the response. A user can paste the header value into a support ticket and you find their request instantly
  • PushProperty inside a using. The property is scoped to the async flow and popped when the request ends — it cannot leak into another request
  • Register it early. Any middleware placed before it logs without the id
Requirement
Nothing appears in the output unless the logger was built with .Enrich.FromLogContext(). This is the single most common reason correlation IDs "do not work".
The Same Thing as a Middleware Class
Middleware/CorrelationIdMiddleware.cs
public class CorrelationIdMiddleware
{
    private const string HeaderName = "X-Correlation-Id";
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[HeaderName].FirstOrDefault();

        if (string.IsNullOrWhiteSpace(correlationId))
            correlationId = Guid.NewGuid().ToString();

        context.Items[HeaderName] = correlationId;

        context.Response.OnStarting(() =>
        {
            context.Response.Headers[HeaderName] = correlationId;
            return Task.CompletedTask;
        });

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await _next(context);
        }
    }
}
Response.OnStarting sets the header just before the response is written — safe even when a later component has already begun composing the body.
One Request, End to End
console output — filtered by CorrelationId
[14:22:07 INF] HTTP POST /api/tasks started            {CorrelationId="9f2c31de"}
[14:22:07 DBG] Validating CreateTaskDto                 {CorrelationId="9f2c31de"}
[14:22:07 INF] Creating task "Write report" for user 88 {CorrelationId="9f2c31de"}
[14:22:07 DBG] SaveChanges affected 1 row               {CorrelationId="9f2c31de"}
[14:22:07 INF] Task 41 created by user 88               {CorrelationId="9f2c31de"}
[14:22:07 INF] HTTP POST /api/tasks responded 201 in 42.7188 ms {CorrelationId="9f2c31de"}
Six entries from four different classes, joined by one value. In Seq this is a single click; in a file it is one grep.
Linking Errors Back to Logs
  • Session 10 gave every error response a traceId. Set it to the correlation ID
  • The client sees an opaque id; you see every log entry behind it
  • Never put the exception message in the response — the id is the safe way to connect the two
  • Support process: user reports the id, you filter the log store, you have the stack trace
{
  "type": "https://httpstatuses.io/500",
  "title": "An unexpected error occurred",
  "status": 500,
  "traceId": "9f2c31de-5b0a-4a1e-9d33-8c7f0f6ad2b1"
}
Rule
Opaque outside, precise inside.
Propagating the ID Downstream
Http/CorrelationIdHandler.cs
public class CorrelationIdHandler : DelegatingHandler
{
    private const string HeaderName = "X-Correlation-Id";
    private readonly IHttpContextAccessor _accessor;

    public CorrelationIdHandler(IHttpContextAccessor accessor) => _accessor = accessor;

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (_accessor.HttpContext?.Items[HeaderName] is string id
            && !request.Headers.Contains(HeaderName))
        {
            request.Headers.Add(HeaderName, id);
        }

        return base.SendAsync(request, cancellationToken);
    }
}

// Program.cs
builder.Services.AddHttpClient("reporting")
    .AddHttpMessageHandler<CorrelationIdHandler>();
Every outgoing call now carries the same id, so a downstream service's logs join yours — the first real step toward distributed tracing.
Health Checks
An endpoint that answers, in a few milliseconds, whether this instance should receive traffic
In this section
Liveness vs readiness
How a probe actually works
Registering built-in checks
Writing a custom IHealthCheck
Tags, multiple endpoints, JSON output
Who Asks "Are You Healthy?"
Load balancer
Stops routing traffic to an instance that fails the check
Kubernetes
Restarts a dead container; withholds traffic from one still warming up
Uptime monitor
Polls every 30 seconds and pages the on-call engineer
Deployment script
Waits for green before shifting traffic to the new version
Why not just call an endpoint
GET /api/tasks requires a token, touches business data, and returns 200 even when a dependency the API needs is broken. A health check is a purpose-built, anonymous, cheap answer.
How a Probe Flows Through the API
Orchestrator Kubernetes, load balancer, uptime monitor GET /health/live Is the process alive? No dependencies checked GET /health/ready Can it serve traffic? Runs every check tagged "ready" AddDbContextCheck CanConnectAsync() — 3 ms AddUrlGroup external-service — 41 ms Custom IHealthCheck Degraded — queue depth high Aggregated status Healthy -> 200 OK Degraded -> 200 OK Unhealthy -> 503 Worst result wins
The report is the worst individual result — one Unhealthy check makes the whole endpoint return 503.
Liveness vs Readiness
Liveness — /health/liveReadiness — /health/ready
QuestionIs the process alive?Can it serve traffic right now?
ChecksNone — returns 200 if the app respondsDatabase, cache, external dependencies
On failureRestart the containerStop sending traffic; do not restart
FrequencyEvery few secondsEvery 10–30 seconds
The mistake that causes outages
Putting a database check behind liveness. When the database blips, every instance fails liveness at once, Kubernetes restarts them all, and a recoverable database hiccup becomes a full outage.
Registering and Mapping Checks
Program.cs
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddUrlGroup(new Uri("https://api.external-service.com/health"), "external-service");

var app = builder.Build();

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});
GET /health returns { "status": "Healthy" } with 200, or 503 when any registered check reports Unhealthy.
Tags Give You Three Endpoints
Program.cs
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(
        name: "database",
        failureStatus: HealthStatus.Unhealthy,
        tags: new[] { "ready", "db" })
    .AddUrlGroup(
        new Uri("https://api.external-service.com/health"),
        name: "external-service",
        tags: new[] { "ready", "external" });

// Liveness: no checks at all, just "the process answered"
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

app.MapHealthChecks("/health");   // everything
Predicate = _ => false selects no checks — the endpoint returns 200 purely because the process was able to answer.
Writing a Custom Health Check
HealthChecks/ReportingServiceHealthCheck.cs
public class ReportingServiceHealthCheck : IHealthCheck
{
    private readonly HttpClient _client;

    public ReportingServiceHealthCheck(HttpClient client) => _client = client;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var response = await _client.GetAsync("/ping", cancellationToken);

            return response.IsSuccessStatusCode
                ? HealthCheckResult.Healthy("Reporting service reachable")
                : HealthCheckResult.Degraded(
                    $"Reporting service returned {(int)response.StatusCode}");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Reporting service unreachable", ex);
        }
    }
}
Register it with .AddCheck<ReportingServiceHealthCheck>("reporting", tags: new[] { "ready" }). Never let a check throw — catch and return Unhealthy instead.
Three Statuses, Two HTTP Codes
StatusMeaningDefault HTTP code
HealthyEverything this check covers is working200 OK
DegradedWorking, but slow or partially impaired — still serve traffic200 OK
UnhealthyCannot serve requests that depend on this component503 Service Unavailable
Degraded returns 200 by design
A slow cache should not remove the instance from the load balancer. If you want Degraded to fail the probe, override it with ResultStatusCodes on HealthCheckOptions.
Timeouts
Give every dependency check a timeout. A health endpoint that hangs for 30 seconds is worse than one that fails fast — the probe times out and the instance is killed anyway.
A Useful JSON Response
Program.cs
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = async (context, report) =>
    {
        context.Response.ContentType = "application/json";

        var payload = new
        {
            status = report.Status.ToString(),
            totalDurationMs = report.TotalDuration.TotalMilliseconds,
            checks = report.Entries.Select(entry => new
            {
                name = entry.Key,
                status = entry.Value.Status.ToString(),
                description = entry.Value.Description,
                durationMs = entry.Value.Duration.TotalMilliseconds
            })
        };

        await context.Response.WriteAsJsonAsync(payload);
    }
});
Note what is not here: no exception messages, no connection strings. The health endpoint is usually anonymous.
What the Monitor Receives
GET /health — 200 OK
{
  "status": "Degraded",
  "totalDurationMs": 48.11,
  "checks": [
    {
      "name": "database",
      "status": "Healthy",
      "description": null,
      "durationMs": 3.42
    },
    {
      "name": "reporting",
      "status": "Degraded",
      "description": "Reporting service returned 502",
      "durationMs": 44.69
    }
  ]
}
The overall status is the worst individual result. Degraded still returns 200, so this instance keeps serving traffic while someone investigates the reporting service.
Wiring the Probes Up
Dockerfile / terminal
# Dockerfile — container-level check
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD curl --fail http://localhost:8080/health/live || exit 1

# Verify locally
curl -i http://localhost:5001/health
curl -s http://localhost:5001/health/ready | jq

# Stop the database and watch the status change
docker stop taskapi-postgres
curl -i http://localhost:5001/health/ready
# HTTP/1.1 503 Service Unavailable
start-period is what stops a slow-starting app from being killed before it has finished warming up.
Health Check Pitfalls
  • Checking everything on liveness. One flaky dependency restarts every instance in the cluster
  • Expensive checks. SELECT COUNT(*) FROM Tasks every five seconds is a self-inflicted load test — use CanConnectAsync
  • Leaking detail. An anonymous endpoint returning exception text hands an attacker your schema and infrastructure
  • Logging every probe at Information. A probe every 5 seconds is 17,000 entries a day of pure noise
  • No timeout. A hanging check makes the endpoint hang, and the probe cannot distinguish that from a crash
Filter probe noise in Serilog
Exclude the health path from request logging with options.GetLevel, or drop it with Filter.ByExcluding(Matching.WithProperty<string>("RequestPath", p => p.StartsWith("/health"))).
Key Concept
"A log you can only read is a story. A log you can query is evidence — and the correlation ID is what turns a thousand interleaved lines back into one request."
— Session 23
Lab — Add Logging to the Task API

Work through these steps on your Task Management API project:

01
Install and configure Serilog with Console and File sinks, rolling daily, and call builder.Host.UseSerilog()
02
Add structured log entries in TaskService for create, update, and delete — message templates with {TaskId} and {UserId}, never interpolation
03
Add a correlation ID middleware that reuses X-Correlation-Id when present and echoes it back on the response
04
Add health checks for the database connection and map /health, /health/live, and /health/ready
05
Verify the logs show CorrelationId in every entry related to a single request — create a task, then filter the file by that one id
Summary
1
Serilog is a provider — keep injecting ILogger<T>; only Program.cs changes
2
Message templates, never interpolation. "Task {TaskId} created by user {UserId}" is searchable; $"..." is not
3
Levels are a contract: Information for normal flow, Warning for recoverable surprises, Error for handled failures, Fatal when the app stops
4
A correlation ID plus Enrich.FromLogContext() reassembles one request out of interleaved output — and links to traceId in ProblemDetails
5
Liveness answers "am I alive", readiness answers "can I serve traffic". Never put dependency checks behind liveness
6
Passwords, tokens and personal data never go in a log — log ids, not entities, and never destructure a user
What's Next

Session 24 — Deployment & Environment Configuration

  • Layering appsettings.json, environment-specific files, and environment variables
  • Keeping secrets out of source control with user secrets and environment configuration
  • Publishing the API and running it behind a real host
  • Different Serilog levels and sinks per environment — exactly the configuration you wrote today
Before next session
Move your Serilog setup into appsettings.json and bind it with ReadFrom.Configuration. Next session we split it per environment — that only works if the configuration is no longer hard-coded in Program.cs.
Assignment

Make your Task Management API observable: full Serilog setup, correlation IDs end to end, and health endpoints — plus a short OBSERVABILITY.md explaining your level choices.

  • Serilog configured from appsettings.json with Console and rolling File sinks, retainedFileCountLimit set, and a Microsoft override at Warning
  • Structured entries in TaskService for create, update and delete, using message templates with {TaskId} and {UserId} — no interpolated strings anywhere
  • Correlation ID middleware that reuses an incoming X-Correlation-Id, echoes it on the response, and feeds the traceId of your ProblemDetails errors
  • Three mapped endpoints — /health, /health/live (no checks), /health/ready (database check, tagged) — with a JSON response writer
  • A log excerpt in OBSERVABILITY.md showing one request's entries sharing a single CorrelationId, plus the 503 body from /health/ready with the database stopped
Bonus
Write a custom IHealthCheck that reports Degraded when the count of overdue tasks exceeds a configured threshold and Unhealthy when the database cannot be reached, then filter health-probe requests out of your Serilog request logging so they do not flood the file sink.
Questions?
Session 23 — Logging, Monitoring & Health Checks