Building “Production-Grade” APIs in .NET - Part 3: Logging Like a Pro in .NET

Author
By Giannis Georgopoulos ·


Series: Building "Production-Grade" APIs in .NET

  1. Intro - Building “Production-Grade” APIs in .NETBuilding “Production-Grade” APIs in .NET – IntroSeries: Building "Production-Grade" APIs in .NET 1. Intro - Building “Production-Grade” APIs in .NET 1. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse Consumers 1. Part 2 - A Professional Looking API 1. Part 3 - Logging Like a Pro in .NET 1. Part 4 - Observability .NET Many engineers build and deploy APIs into production. So we have an API running in production — does that mean it’s truly production-grade? More often than not, the answer is no. We write the code, test it lo
  2. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse ConsumersBuilding “Production-Grade” APIs in .NET - Part 1: Designing Clean and Intuitive APIsSeries: Building "Production-Grade" APIs in .NET 1. Intro - Building “Production-Grade” APIs in .NET 1. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse Consumers 1. Part 2 - A Professional Looking API 1. Part 3 - Logging Like a Pro in .NET 1. Part 4 - Observability .NET Let’s say you join a new team and find this in one of the core API controllers: [HttpPost("updateOrder")] public async Task UpdateOrder([FromBody] OrderDto order) { var updatedOrder = await _orderService.Upd
  3. Part 2 - A Professional Looking APIBuilding “Production-Grade” APIs in .NET – Part 2: A Professional Looking APISeries: Building "Production-Grade" APIs in .NET 1. Intro - Building “Production-Grade” APIs in .NET 1. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse Consumers 1. Part 2 - A Professional Looking API 1. Part 3 - Logging Like a Pro in .NET 1. Part 4 - Observability .NET Your OpenAPI spec is the contract that defines how consumers interact with your API. It powers visual tools that help developers understand, test, and integrate with your endpoints. This spec powers visual tools
  4. Part 3 - Logging Like a Pro in .NETBuilding “Production-Grade” APIs in .NET - Part 3: Logging Like a Pro in .NETSeries: Building "Production-Grade" APIs in .NET 1. Intro - Building “Production-Grade” APIs in .NET 1. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse Consumers 1. Part 2 - A Professional Looking API 1. Part 3 - Logging Like a Pro in .NET 1. Part 4 - Observability .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 sign
  5. Part 4 - Observability .NETBuilding “Production-Grade” APIs in .NET - Part 4: You Can’t Fix What You Can’t See: Observability in .NET APIs"Series: Building "Production-Grade" APIs in .NET 1. Intro - Building “Production-Grade” APIs in .NET 1. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse Consumers 1. Part 2 - A Professional Looking API 1. Part 3 - Logging Like a Pro in .NET 1. Part 4 - Observability .NET Logs are a great starting point — but they’re not enough. In small systems, logs might be all you need. But once you’re dealing with a distributed system, logging alone quickly falls short. Imagine a typical r

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 Part 1Building “Production-Grade” APIs in .NET - Part 1: Designing Clean and Intuitive APIsSeries: Building "Production-Grade" APIs in .NET 1. Intro - Building “Production-Grade” APIs in .NET 1. Part 1 - Designing Clean, Intuitive APIs That Don’t Confuse Consumers 1. Part 2 - A Professional Looking API 1. Part 3 - Logging Like a Pro in .NET 1. Part 4 - Observability .NET Let’s say you join a new team and find this in one of the core API controllers: [HttpPost("updateOrder")] public async Task UpdateOrder([FromBody] OrderDto order) { var updatedOrder = await _orderService.Upd.

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<OrderController> logger and wrap the call:


[ApiController]  
[Route("api/v{v:apiVersion}/[controller]")]  
public class OrderController(OrderService orderService, ILogger<OrderController> logger) : ControllerBase  
{  
....
    /// <summary>  
    /// Sets the shipping address for an order.    /// </summary>    /// <response code="400">    /// Returned when:    /// - 'ZipCode' is missing or invalid    /// - The order cannot be updated due to its current status    /// </response>    [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.