Structured logging and correlation ids in ASP.NET Core: debugging production without guessing
Short answer: log events with properties, not sentences. Use Serilog (or plain ILogger) with message templates, write JSON so logs are searchable, give every request a correlation id, push it into every log line, and return it in the response so a user or the frontend can report it. Then debugging a production issue starts with one query instead of an afternoon of scrolling.
A real example: "the payment failed around 3 pm"
Support forwards a complaint: a payment failed at about 3 pm. The log file has 40,000 lines between 2:50 and 3:10, all plain text like Payment failed for user 5521. You grep for the user id, find three failures, and can't tell which one belongs to this request or what happened before it in the gateway client.
With structured logs, the error page showed the user a short code: the correlation id. One filter, CorrelationId = '7f3c...', returns the request line, the gateway call, the retry, the timeout and the final status, in order. Five minutes instead of two hours.
Setup with Serilog
Packages: Serilog.AspNetCore and Serilog.Formatting.Compact.
using Serilog;
using Serilog.Formatting.Compact;
builder.Host.UseSerilog((ctx, services, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(new CompactJsonFormatter(), "logs/app-.json",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14));
Correlation id plus request logging:
using System.Diagnostics;
using Serilog.Context;
var app = builder.Build();
// 1. Correlation first, so every later log line carries it
app.Use(async (ctx, next) =>
{
var correlationId = ctx.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Activity.Current?.TraceId.ToString()
?? ctx.TraceIdentifier;
ctx.Response.Headers["X-Correlation-ID"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next();
}
});
// 2. One summary line per request: method, path, status, elapsed ms
app.UseSerilogRequestLogging();
Order matters: the correlation middleware runs before UseSerilogRequestLogging, so the request summary line also gets the id.
Write templates, not strings
// Bad: one unique string per call, nothing to filter on
log.LogInformation($"Withdraw {amount} from {walletId} failed");
// Good: a template plus properties you can query
log.LogWarning("Withdraw of {AmountRials} from {WalletId} failed: {Reason}",
amount, walletId, error.Code);
The template version stores AmountRials, WalletId and Reason as separate fields, so you can ask "all failed withdrawals over 50 million Rials today" instead of searching text.
Practical notes
- Show the id to users. Put the correlation id on your error page and in ProblemDetails responses. Users copy it into a support ticket and you have the exact request.
- Across services,
HttpClientalready forwards the W3Ctraceparentheader, soActivity.TraceIdfollows the call chain. Keep the custom header for clients that can't sendtraceparent. - On IIS, make sure the app pool identity can write to the
logsfolder, and keep the folder outsidewwwroot. A free Seq instance on the same server makes JSON logs searchable in a browser. - Levels:
Informationfor business events,Warningfor handled failures,Errorfor things someone must look at. If every line is an error, nothing is.
Pros and cons
Pros
- Incidents are investigated by query, not by guesswork.
- One id ties together the user report, the request and every downstream call.
- The same logs feed dashboards and alerts later without code changes.
Cons
- JSON logs are harder to read raw; you want a viewer such as Seq.
- More properties mean more storage; set retention and levels deliberately.
- Logging sensitive data by accident becomes easier, so review new log lines.
Production issues that take days to explain? See how I can help.
Frequently asked questions
Why not just use ILogger without Serilog?
You can: ILogger already supports message templates and scopes. Serilog adds sinks (rolling JSON files, Seq, Elasticsearch), enrichers and request logging that are tedious to build yourself. Your code still logs through ILogger either way.
Is TraceId enough, or do I need my own correlation header?
Between .NET services, Activity.TraceId is propagated automatically with the W3C traceparent header, so TraceId is enough. A custom X-Correlation-ID helps when the caller is a mobile app or partner that can't send traceparent.
What should never be logged?
Card numbers, CVV, passwords, OTP codes, tokens, full national IDs and full request bodies of payment calls. Log ids and masked values instead, and review new log lines for this in code review.