Logging Like a Pro in .NET
Logs are your primary tool for understanding what your API is doing in production. Whether it's debugging a bug report, identifying performance issues, or reacting to an incident logs are your first and best signal. But many developers fall into two extremes: Too little logging, and you're blind in production. Too much logging, and you're drowning in noise, cost, or leaked sensitive data. In this post, we’ll take a smarter approach and cover: Setting up Serilog for structured logging in .NET Logging exceptions using source generators Outputting request payloads as structured JSON Masking sensitive data to stay compliant (GDPR, etc.) Enriching logs with contextual information like OrderId Let’s dive in. Step 1: Adding serilog to your project dotnet add package Serilog.AspNetCore dotnet add package Serilog.Sinks.Console We’ll use the console sink for simplicity because it works out of the box, doesn’t require any extra setup, and still gives us structured log output in your terminal or Docker logs. Once you're ready for production, you can plug in any of Serilog’s many available sinks, like Seq, Application Insights, etc. Step 2: Configure Serilog in Program.cs Here’s a simple Serilog setup that logs to the console: Log.Logger = new LoggerConfiguration() .WriteTo .Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{EventId}:{EventName}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); var builder = WebApplication.CreateBuilder(args); builder.Services.AddSerilog(); var app = builder.Build(); // your app setup... app.Run(); With just a few lines of setup, Serilog is now capturing structured logs to the console. We will now launch our api and call the /api/v2/Order/{orderId}/shipping endpoint from [[production-ready-api-devex|Part 1]]. If we take a look at the console now we will see the HTTP request being logged. By default, all incoming HTTP requests will be logged, including successful ones. While this might seem helpful, it can quickly overwhelm your logs, especially when most requests succeed and add no diagnostic value. Even worse, platforms like Application Insights charge by the volume of logs ingested. For detailed traces, I recommend using OpenTelemetry with sampling. Logs should be reserved for intentional diagnostics, not every HTTP 200. To reduce noise, disable default HTTP logs: .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) You could also use appsettings.json for configuring serilog. Read more here Step 3: Logging our first exception So if we don't want to log our HTTP requests what do we want to log? The first thing is exceptions. When something goes wrong in our system, we need visibility. I’ll cover global exception handling in a later article to keep the scope small. First, let’s simulate an exception in OrderService.cs: public async Task SetShippingInfo(Guid id, SetShippingInfoRequest request) { throw new Exception("Oops! Failed to set shipping info"); } Now update the controller to catch and log the error. Inject ILogger logger and wrap the call: [ApiController] [Route("api/v{v:apiVersion}/[controller]")] public class OrderController(OrderService orderService, ILogger logger) : ControllerBase { .... /// /// Sets the shipping address for an order. /// /// /// Returned when: /// - 'ZipCode' is missing or invalid /// - The order cannot be updated due to its current status /// [MapToApiVersion("2")] [HttpPatch("{orderId:guid}/shipping")] public async Task SetShippingInfo(Guid orderId, SetShippingInfoRequest request) { try { await orderService.SetShippingInfo(orderId, request); } catch (Exception ex) { logger.LogError(ex, "Failed to set shipping info for request:{Request}", request); return Problem( detail: ex.StackTrace, title: ex.Message); } return NoContent(); }} Step 4: Use Source-Generated Logging The previous approach works but we can do better. .NET provides high-performance source-generated logging, which avoids boxing, allocations, and improves log searchability via EventId and EventName. Update the controller to use a static partial log method: [LoggerMessage( EventId = 1, EventName = nameof(LogFailedSetShippingInfo), Level = LogLevel.Error, Message = "Failed to set shipping info for {Request}")] static partial void LogFailedSetShippingInfo(ILogger logger, Exception exception, SetShippingInfoRequest request); The event id and name we defined in the LoggerMessage attribute will be extremely useful later on, when we will want to

Logs are your primary tool for understanding what your API is doing in production.
Whether it's debugging a bug report, identifying performance issues, or reacting to an incident logs are your first and best signal.
But many developers fall into two extremes:
- Too little logging, and you're blind in production.
- Too much logging, and you're drowning in noise, cost, or leaked sensitive data.
In this post, we’ll take a smarter approach and cover:
- Setting up Serilog for structured logging in .NET
- Logging exceptions using source generators
- Outputting request payloads as structured JSON
- Masking sensitive data to stay compliant (GDPR, etc.)
- Enriching logs with contextual information like
OrderId
Let’s dive in.
Step 1: Adding serilog to your project
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
We’ll use the console sink for simplicity because it works out of the box, doesn’t require any extra setup, and still gives us structured log output in your terminal or Docker logs.
Once you're ready for production, you can plug in any of Serilog’s many available sinks, like Seq, Application Insights, etc.
Step 2: Configure Serilog in Program.cs
Here’s a simple Serilog setup that logs to the console:
Log.Logger = new LoggerConfiguration()
.WriteTo
.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{EventId}:{EventName}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog();
var app = builder.Build();
// your app setup...
app.Run();
With just a few lines of setup, Serilog is now capturing structured logs to the console.
We will now launch our api and call the /api/v2/Order/{orderId}/shipping
endpoint from [[production-ready-api-devex|Part 1]].
If we take a look at the console now we will see the HTTP request being logged.
By default, all incoming HTTP requests will be logged, including successful ones.
While this might seem helpful, it can quickly overwhelm your logs, especially when most requests succeed and add no diagnostic value. Even worse, platforms like Application Insights charge by the volume of logs ingested.
For detailed traces, I recommend using OpenTelemetry with sampling. Logs should be reserved for intentional diagnostics, not every HTTP 200.
To reduce noise, disable default HTTP logs:
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
You could also use
appsettings.json
for configuring serilog. Read more here
Step 3: Logging our first exception
So if we don't want to log our HTTP requests what do we want to log?
The first thing is exceptions. When something goes wrong in our system, we need visibility.
I’ll cover global exception handling in a later article to keep the scope small.
First, let’s simulate an exception in OrderService.cs
:
public async Task SetShippingInfo(Guid id, SetShippingInfoRequest request)
{
throw new Exception("Oops! Failed to set shipping info");
}
Now update the controller to catch and log the error. Inject ILogger
and wrap the call:
[ApiController]
[Route("api/v{v:apiVersion}/[controller]")]
public class OrderController(OrderService orderService, ILogger<OrderController> logger) : ControllerBase
{
....
///
/// Sets the shipping address for an order. /// /// /// Returned when: /// - 'ZipCode' is missing or invalid /// - The order cannot be updated due to its current status /// [MapToApiVersion("2")]
[HttpPatch("{orderId:guid}/shipping")]
public async Task<IActionResult> SetShippingInfo(Guid orderId, SetShippingInfoRequest request)
{
try
{
await orderService.SetShippingInfo(orderId, request);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to set shipping info for request:{Request}", request);
return Problem(
detail: ex.StackTrace,
title: ex.Message);
}
return NoContent();
}}
Step 4: Use Source-Generated Logging
The previous approach works but we can do better.
.NET provides high-performance source-generated logging, which avoids boxing, allocations, and improves log searchability via EventId
and EventName
.
Update the controller to use a static partial log method:
[LoggerMessage(
EventId = 1,
EventName = nameof(LogFailedSetShippingInfo),
Level = LogLevel.Error,
Message = "Failed to set shipping info for {Request}")]
static partial void LogFailedSetShippingInfo(ILogger logger, Exception exception, SetShippingInfoRequest request);
The event id and name we defined in the LoggerMessage
attribute will be extremely useful later on, when we will want to search our logs and add rules for automated alerting.
Step 5: Controlling Log Output for Complex Objects
Let’s tweak our log message to include the actual request payload:
[LoggerMessage(EventId = 1, EventName = nameof(LogFailedSetShippingInfo), Level = LogLevel.Error, Message = "Failed to set shipping info for {@Request}")]
static partial void LogFailedSetShippingInfo(ILogger logger, Exception exception, SetShippingInfoRequest request);
The key difference here is the @
symbol in {@Request}
. Without it, your logs will just say:
Failed to set shipping info for SetShippingInfoRequest
But with @
, Serilog serializes the entire object as structured JSON and includes all public properties of SetShippingInfoRequest
in your logs. This makes it much easier to understand what actually went wrong.
However, this technique comes with an important caveat.
When logging full objects, you risk unintentionally including sensitive data like full recipient names, emails, phone numbers, or tokens. This can quickly lead to GDPR violations or internal policy breaches, especially if logs are forwarded to external systems like Application Insights or Seq.
Step 6: Redacting Sensitive Data
To avoid leaking sensitive data, we’ll use the Serilog.Enrichers.Sensitive
package, which allows you to redact specific properties automatically.
Install the package:
dotnet add package Serilog.Enrichers.Sensitive
Then, in your Program.cs
, configure Serilog to redact sensitive properties:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.Enrich.WithSensitiveDataMasking(options =>
{
options.MaskProperties.Add("RecipientName");
}) .WriteTo
.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{EventId}:{EventName}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
Now the RecipientName
property will be automatically be masked in the logs, so running our example will give us this:
[22:09:28 ERR] [{ Id: 1, Name: "LogFailedSetShippingInfo" }:]
Failed to set shipping info for {"RecipientName": "***MASKED***", "Street": "123 Main St", "City": "New York", "State": "NY", "ZipCode": "10001", "Country": "USA"}
System.Exception: OOPS Failed to set shipping info
at BloggingExamples.OrderService.SetShippingInfo(Guid id, SetShippingInfoRequest request) in /.../BloggingExamples/OrderService.cs:line 12
at BloggingExamples.OrderController.SetShippingInfo(Guid orderId, SetShippingInfoRequest request) in /.../BloggingExamples/OrderController.cs:line 42
You can mask by property name, regex pattern, or type name. This makes it easy to comply with GDPR and protect your users.
Step 6: Add Contextual Properties Using LogContext
Sometimes you want to include additional information (like the current order ID, tenant ID, or user ID) in every log line within a given scope. You can do this using LogContext
.
In the controller, push the orderId
into the log context:
using Serilog.Context;
...
[HttpPatch("{orderId:guid}/shipping")]
public async Task<IActionResult> SetShippingInfo(Guid orderId, SetShippingInfoRequest request)
{
using var logContext = LogContext.PushProperty("orderId", orderId);
try
{
await orderService.SetShippingInfo(orderId, request);
}
catch (Exception ex)
{
LogFailedSetShippingInfo(logger, ex, request);
return Problem(
detail: ex.StackTrace, // Don't return the stacktrace in production
title: ex.Message);
}
return NoContent();
}
Every log within that scope, including the one from LogFailedSetShippingInfo
, will now automatically include the OrderId
property in its structured output.
This is incredibly useful for filtering logs by entity, user, or request—without cluttering every log call manually.
We haven’t set up user or tenant context here, but in a real app, the most common pattern is to push those values into the log context from middleware.
With this setup, you’re no longer logging blindly, you’re logging with purpose, clarity, and safety.
If you enjoyed this post visit my blog for more.