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:00
Why logging matters in production
Discussion
10 min
0:10
Serilog setup & structured logs
Demo
30 min
0:40
Log levels — when to use each
Theory
15 min
0:55
Correlation IDs
Demo
15 min
1:10
Health check endpoints
Demo
20 min
1:30
Lab — add logging to the Task API
Lab
25 min
1:55
Wrap-up
Discussion
5 min
Section 1 of 6
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.
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.
Section 2 of 6
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.
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
Call
Effect
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
{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
Sink
Package
Use it for
Console
Serilog.Sinks.Console
Local development; container stdout in production
File
Serilog.Sinks.File
Rolling files on a VM or single server
Seq
Serilog.Sinks.Seq
Searching structured logs by property — ideal in class
Elasticsearch
Serilog.Sinks.Elasticsearch
Large-scale central log search
Application Insights
Serilog.Sinks.ApplicationInsights
Azure-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.
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.
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"
Section 3 of 6
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
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:
Failures that are handled but should be investigated
Fatal
Application 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
Environment
Minimum level
Sinks
Why
Development
Debug
Console
You are reading it live; volume does not matter
Staging
Debug or Information
Console + File
Reproducing production bugs with more detail
Production
Information
File + Seq
Signal 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.
Section 5 of 6
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"}
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
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.
Section 6 of 6
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
The report is the worst individual result — one Unhealthy check makes the whole endpoint return 503.
Liveness vs Readiness
Liveness — /health/live
Readiness — /health/ready
Question
Is the process alive?
Can it serve traffic right now?
Checks
None — returns 200 if the app responds
Database, cache, external dependencies
On failure
Restart the container
Stop sending traffic; do not restart
Frequency
Every few seconds
Every 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
Status
Meaning
Default HTTP code
Healthy
Everything this check covers is working
200 OK
Degraded
Working, but slow or partially impaired — still serve traffic
200 OK
Unhealthy
Cannot serve requests that depend on this component
503 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.
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
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.