A while back I wrote about why application logs are your friend: when something breaks in production, read your logs before you panic and roll back. That post assumed the logs were already good. It never asked the harder question: who wrote them, and did they write them well? Good application logging is a skill, not an afterthought, and most of us pick it up the hard way, one 2 AM incident at a time.
This post is the part I skipped. It covers picking the right log level, why structured logging beats string concatenation, how to keep PII out of your logs without hand-rolling a redaction pass every time, and how sinks actually route your log events once they leave the call site.
Picking the right log level
Every logging framework ships the same rough ladder: Trace, Debug, Information, Warning, Error, Fatal (Serilog calls the top and bottom Verbose and Fatal; .NET’s ILogger calls them Trace and Critical, same idea). Knowing the definitions doesn’t tell you when to reach for each one, so here’s the version I actually use.
Trace and Debug are for you, right now, while you’re building or debugging a specific piece of code. Method entry/exit, variable values, loop iterations. Turn these off in production by default; they’re noise to everyone except the person actively chasing a bug.
Information is for things that matter to the business, not just the code. A customer placed an order. A user logged in. A background job finished. If you’d want to know this happened even when nothing went wrong, it’s Information. If you’re logging every database call at this level, you’re drowning the signal you actually need.
Warning means “not broken yet.” A retry succeeded on the second attempt. A cache missed and fell back to the database. Stock dropped below a reorder threshold. Nothing failed, but somebody should probably look at the trend before it does.
Error means something failed and a human should find out, but the process is still running. A payment processor declined a card. An API call timed out and the request failed. Always attach the exception object, not just its message; you’ll want the stack trace later even if you don’t need it today.
Fatal (or Critical) means the process can’t continue. The database connection string is missing. A required dependency never came up. You log these right before the app exits, and they should be rare enough that a Fatal log is itself a signal worth paging someone over.
A useful rule of thumb: if you’re not sure whether something is Information or Debug, ask whether you’d want to see it in a report of “what did the app actually do today.” If yes, Information. If it’s only useful while staring at this one function, Debug.
Why structured application logging (and why Serilog)
Plenty of code still logs like this:
Console.WriteLine($"Order {orderId} failed for customer {customerName}: {ex.Message}");
That line reads fine in a terminal and is nearly useless everywhere else. You can’t filter it by order ID without a regex. You can’t ask “how many orders failed for this customer in the last hour” without parsing text you control but never designed for parsing. The moment you need to search, aggregate, or alert on your logs, string concatenation turns into an archaeology project.
Structured logging fixes this by keeping your data as data. Instead of baking values into a string, you log named properties alongside a message template:
Log.Error(ex, "Order {OrderId} failed for customer {CustomerName}", orderId, customerName);
The rendered message still reads the same, but OrderId and CustomerName are stored as actual properties on the log event. Ship that to a system that understands structured data (Seq, Elasticsearch, even a JSON file) and now you can query on OrderId directly, without ever touching a regex.
This is why I reach for Serilog over Console.WriteLine or a hand-rolled logging helper. It’s not the only structured logging library for .NET (Microsoft.Extensions.Logging supports structured logging too, and Serilog often runs underneath it), but it gives you the full pipeline in one place: message templates, destructuring, enrichment, and a huge ecosystem of sinks, without writing any of that plumbing yourself.
Hiding PII and sensitive data
Structured logging has a sharp edge: it makes it trivially easy to accidentally log an entire object, credit card number and all, because you destructured it with @ instead of thinking about what’s inside. This is where most teams get burned, so it’s worth solving properly instead of trusting everyone to remember by hand.
Attribute-driven destructuring, the recommended approach
My preferred approach is an attribute-driven destructuring policy. Mark the sensitive properties once on the model:
public sealed class Order
{
public int Id { get; init; }
public required string CustomerName { get; init; }
[Sensitive]
public required string Email { get; init; }
[Sensitive]
public required string CreditCardNumber { get; init; }
public decimal Amount { get; init; }
}
Then register a custom IDestructuringPolicy that checks for the attribute and masks anything it finds, falling back to Serilog’s default behavior for everything else:
public sealed class SensitiveDataDestructuringPolicy : IDestructuringPolicy
{
public bool TryDestructure(object value, ILogEventPropertyValueFactory factory, out LogEventPropertyValue result)
{
var properties = value.GetType().GetProperties();
if (!properties.Any(p => p.GetCustomAttribute<SensitiveAttribute>() is not null))
{
result = null!;
return false;
}
var members = properties.Select(p => new LogEventProperty(
p.Name,
p.GetCustomAttribute<SensitiveAttribute>() is not null
? new ScalarValue("***REDACTED***")
: factory.CreatePropertyValue(p.GetValue(value), destructureObjects: true)));
result = new StructureValue(members);
return true;
}
}
Wire it in with Destructure.With<SensitiveDataDestructuringPolicy>(), and every {@Order} you ever log comes out with Email and CreditCardNumber masked automatically. Nobody has to remember to redact anything by hand; the model tells the logger what to hide, once.
A property-name enricher as a safety net
That covers properties on a model, but sensitive values also leak in through loose, ad hoc log properties: Log.Information("Resetting password for {Email}", user.Email) never destructures an object, so the attribute above never gets a chance to run. For that case, a second layer helps: an ILogEventEnricher that scans every log event’s properties by name and redacts anything on a denylist (password, ssn, email, and so on), regardless of where it came from:
public sealed class SensitivePropertyNameEnricher : ILogEventEnricher
{
private static readonly string[] DeniedNames = { "password", "ssn", "creditcardnumber", "email" };
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory factory)
{
foreach (var name in logEvent.Properties.Keys.ToList())
{
if (DeniedNames.Contains(name, StringComparer.OrdinalIgnoreCase))
{
logEvent.AddOrUpdateProperty(factory.CreateProperty(name, "***REDACTED***"));
}
}
}
}
Together these give you defense in depth: the attribute on your model handles the common case precisely, and the enricher catches the property someone logged by name without thinking about it. The companion sample app below wires up both.
How sinks route your application logs
A sink is where a log event goes once you’re done shaping it. Everything you configure before WriteTo (minimum level, enrichers, destructuring policies) happens once per log event, and then Serilog fans the same finished event out to every sink you’ve registered:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.With<SensitivePropertyNameEnricher>()
.Destructure.With<SensitiveDataDestructuringPolicy>()
.WriteTo.Console()
.WriteTo.File("logs/log-.txt", restrictedToMinimumLevel: LogEventLevel.Information, rollingInterval: RollingInterval.Day)
.CreateLogger();
That configuration writes every Debug-and-above event to the console for local development, but only Information-and-above to a daily rolling file, since Debug noise isn’t worth keeping around on disk. Each sink can have its own minimum level, its own output format, and its own destination: console, a rolling file, Seq, Elasticsearch, Application Insights. There are sinks for nearly everything, and adding one is usually a single WriteTo call plus a NuGet package. This is the real payoff of structured logging: you write your log calls once, and where they end up is a configuration decision, not a code change.
Practical takeaways
- Match the log level to the audience: Debug/Trace for you while coding, Information for business events worth keeping, Warning for “not broken yet,” Error for failures a human should see, Fatal for “the process is going down.”
- Use named properties in your message templates instead of string interpolation. You’re not just making logs readable, you’re making them queryable.
- Mark sensitive model properties once with an attribute and let a custom destructuring policy handle the redaction everywhere that model gets logged.
- Add a property-name enricher as a safety net for sensitive values that get logged outside of a model.
- Treat sinks as a routing decision, not a code change. Configure a minimum level and format per sink, and add new destinations without touching your log calls.
Further reading
The companion C# sample app for this post lives alongside it in the GitHub repo, and demonstrates the destructuring policy, the enricher, and multi-sink configuration end to end. For the full API surface behind everything covered here, the Serilog documentation is worth bookmarking. And if you haven’t yet, go read Application logs are your friend first, this post assumes you already believe that part.


0 comments on “Application Logs Are Your Friend (and How to Write Them)”