Less Prose, More Signal: a C# Guide to Honest Logs

Build an ASP.NET Core app with structured JSON logs, request correlation, and honest outcome messages. Avoid noisy events and keep private data out.

A log line should tell you what actually happened

You search for an order ID and find “Order completed.” Helpful, until the next entry says the save failed.

That isn’t a formatting problem. The log claimed a state change before the application had finished it. Adding more lines won’t make that story true.

Let’s build a small ASP.NET Core app with structured JSON logs, request correlation, and messages that match the work. We’ll also keep its limitations visible: this example stores orders in memory, not in a durable database.

Structure starts at the call site

With ILogger, a message template keeps named values separate from the message. LogInformation("Stored order {OrderId}", id) gives the provider an OrderId property. Interpolating id into the string first loses that structured property.

Keep property names consistent. OrderId in one place and OrderNumber in another makes an incident harder to query unless those really mean different things.

The provider and sink still matter. WriteTo.Console() normally produces formatted text. To emit JSON, you must configure a JSON formatter.

A runnable logging app

Use the .NET 10 SDK and PowerShell:

Terminal window
dotnet new web -n HonestLogs -f net10.0
Set-Location HonestLogs
dotnet add package Serilog.AspNetCore --version 10.0.0

Replace Program.cs with this complete file. The Serilog ASP.NET Core package includes the console sink and compact JSON formatter used here.

using System.Collections.Concurrent;
using System.Diagnostics;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using Serilog.Formatting.Compact;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, config) => config
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "HonestLogs")
.WriteTo.Console(new CompactJsonFormatter()));
builder.Services.AddSingleton<ConcurrentDictionary<Guid, int>>();
var app = builder.Build();
app.Use(async (context, next) =>
{
var correlationId = Guid.NewGuid().ToString("N");
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
using (LogContext.PushProperty(
"TraceId", Activity.Current?.TraceId.ToString()))
{
await next(context);
}
});
app.UseSerilogRequestLogging(options =>
{
options.GetLevel = (context, elapsed, error) =>
error is not null || context.Response.StatusCode >= 500
? LogEventLevel.Error
: elapsed > 3000
? LogEventLevel.Warning
: context.Request.Path == "/health"
? LogEventLevel.Verbose
: LogEventLevel.Information;
options.EnrichDiagnosticContext = (diagnostics, context) =>
diagnostics.Set("TraceId", Activity.Current?.TraceId.ToString());
});
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapPost("/orders", (
OrderInput input,
ConcurrentDictionary<Guid, int> orders,
ILoggerFactory factory) =>
{
if (input.Quantity is < 1 or > 100)
{
return Results.BadRequest(new { error = "Quantity must be 1 through 100" });
}
var id = Guid.NewGuid();
orders[id] = input.Quantity;
var log = factory.CreateLogger("Orders");
log.LogInformation(
"Stored order {OrderId} in memory with {Quantity} items",
id, input.Quantity);
return Results.Created($"/orders/{id}", new { id, input.Quantity });
});
app.MapGet("/orders/{id:guid}", (Guid id, ConcurrentDictionary<Guid, int> orders) =>
orders.TryGetValue(id, out var quantity)
? Results.Ok(new { id, quantity })
: Results.NotFound());
app.MapGet("/demo/failure", () =>
{
throw new InvalidOperationException("Demonstration failure");
});
app.Run();
public sealed record OrderInput(int Quantity);

Run it:

Terminal window
dotnet run --no-launch-profile --urls http://localhost:5095

From another terminal, create an order:

Terminal window
Invoke-RestMethod http://localhost:5095/orders -Method Post -ContentType application/json -Body '{"quantity":2}'

The app returns 201 and an identifier. Fetch the returned location to read the order. Quantity zero returns 400. /demo/failure intentionally throws so you can inspect a failed-request event; remove that demonstration route before deploying.

You’ll see newline-delimited JSON. The order event carries OrderId, Quantity, CorrelationId, and a message template. The request-completion event carries the path, status, and elapsed time.

Put the success message after the success

The sample says “Stored … in memory” after inserting the item. It doesn’t claim the order was charged, emailed, or committed to durable storage.

In a database-backed workflow, log a successful commit after it completes. An accepted queue message is not the same as a processed payment. Name the boundary you actually crossed.

If an external payment succeeded but your local save failed, a single “Payment failed” message is also misleading. Capture the individual outcomes and use a recovery design such as idempotency and reconciliation. Logging can’t make two systems transactional.

One request logger is enough

The app registers UseSerilogRequestLogging once. That one configuration handles both enrichment and severity.

Failures take priority over health-check suppression. Otherwise a broken health endpoint can disappear at the very moment you need it. Slow requests also remain visible here.

Ordinary health checks use Verbose, below the configured minimum. Adjust that policy for your operating needs. A health probe failing authentication might deserve attention even when it isn’t a 500.

Request logging belongs before the handlers it needs to time. Middleware earlier in the pipeline can short-circuit before the request logger sees anything.

Correlation is context, not permission

The sample creates a local correlation ID rather than accepting arbitrary inbound header text. This keeps unbounded, untrusted values out of that property.

For cross-service tracing, ASP.NET Core and instrumented HttpClient calls can propagate W3C trace context. Activity.Current connects the request to its trace. Configure collection and export separately; printing a trace ID alone doesn’t send a trace anywhere.

If your organization also forwards an application correlation header, validate its length and format at the boundary. Set it on each outbound HttpRequestMessage, not by mutating a shared client’s DefaultRequestHeaders per request. Concurrent callers shouldn’t overwrite each other’s context.

For background messages, put the necessary correlation metadata on the message and start a scoped log context when handling it. Don’t assume an HTTP request scope survives a queue hop. Neither a trace ID nor a correlation ID should authorize access to data.

Levels should describe the outcome

  • Trace and Debug are detailed diagnostics you selectively enable.
  • Information records useful normal events and state changes.
  • Warning identifies a degraded or unexpected condition worth investigation.
  • Error means the operation failed.
  • Critical means a serious system-level failure, not just an unhappy request.

A validation error isn’t automatically a server error. A retry isn’t automatically a failure of the whole operation. If you log retry attempts, include the attempt number and safe dependency name, then distinguish recovered attempts from final failure.

Don’t retry every unsuccessful HTTP response. A bad request won’t improve with repetition, and retrying a non-idempotent operation can duplicate work.

Log less private data, not less meaning

Avoid request bodies, credentials, payment details, and personal data by default. Even exception messages can contain sensitive values. Review what a sink stores, who can query it, and how long it stays there.

Use identifiers only when they fit your privacy policy. Keep high-cardinality IDs in logs or traces rather than turning each one into a metric label.

At the error boundary you own, pass the exception object to the logger so stack information survives. Avoid catching, logging, and rethrowing the same failure in every layer. Several copies of one exception aren’t several incidents.

Useful logs tell you which operation happened, what it affected, and how it ended. Start there. The dashboard can wait until the story is true.