Structured logging in C# isn’t about making your logs look pretty; it’s about turning them into searchable, queryable data that machines can understand.
Let’s see what structured logging looks like in practice. Imagine a simple web API controller action that processes an order.
[ApiController]
[Route("[controller]")]
public class OrdersController : ControllerBase
{
private readonly ILogger<OrdersController> _logger;
public OrdersController(ILogger<OrdersController> logger)
{
_logger = logger;
}
[HttpPost("{orderId}")]
public IActionResult ProcessOrder(int orderId, [FromBody] OrderDetails order)
{
_logger.LogInformation("Received order processing request for Order ID: {OrderId}", orderId);
if (order == null)
{
_logger.LogWarning("Order details were null for Order ID: {OrderId}", orderId);
return BadRequest("Order details cannot be null.");
}
try
{
// Simulate order processing logic
if (order.Amount <= 0)
{
_logger.LogError("Invalid order amount for Order ID: {OrderId}. Amount: {OrderAmount}", orderId, order.Amount);
return BadRequest("Order amount must be positive.");
}
// ... actual processing ...
_logger.LogInformation("Successfully processed order {OrderId}. Customer: {CustomerId}, Amount: {OrderAmount}",
orderId, order.CustomerId, order.Amount);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "An unexpected error occurred while processing order {OrderId}. Customer: {CustomerId}",
orderId, order.CustomerId);
return StatusCode(StatusCodes.Status500InternalServerError, "An internal error occurred.");
}
}
}
public class OrderDetails
{
public int CustomerId { get; set; }
public decimal Amount { get; set; }
}
When this code runs and you have Serilog configured to output to a file or a logging service (like Seq or Elasticsearch), the log entries generated won’t just be plain text. They’ll look something like this (JSON output example):
{
"@t": "2023-10-27T10:30:00.123Z",
"@mt": "Received order processing request for Order ID: {OrderId}",
"@i": "some-guid-id",
"OrderId": 12345,
"SourceContext": "MyApi.Controllers.OrdersController",
"Level": "Information",
"Message": "Received order processing request for Order ID: 12345"
}
And for an error:
{
"@t": "2023-10-27T10:31:15.456Z",
"@mt": "An unexpected error occurred while processing order {OrderId}. Customer: {CustomerId}",
"@i": "another-guid-id",
"OrderId": 67890,
"CustomerId": 987,
"SourceContext": "MyApi.Controllers.OrdersController",
"Level": "Error",
"Message": "An unexpected error occurred while processing order 67890. Customer: 987",
"Exception": "System.NullReferenceException: Object reference not set to an instance of an object.\n at MyApi.Controllers.OrdersController.ProcessOrder(Int32 orderId, OrderDetails order)\n ..."
}
The @{...} syntax in ILogger’s message template is key. It tells the logging framework (like Serilog) to capture the provided arguments (OrderId, CustomerId, OrderAmount) as distinct properties in the log event, rather than just embedding them into a single string. This is the essence of structured logging: turning log messages into data.
The problem structured logging solves is the inflexibility of traditional, plain-text logs. If you want to find all orders processed with an amount greater than $100, or all errors related to a specific customer ID, you’d have to resort to complex regular expressions or string parsing, which is brittle and inefficient. With structured logs, you can query these fields directly. For example, in Seq, you could search for OrderAmount > 100 or CustomerId = 987 directly in the search bar, and it would efficiently filter the log events.
Serilog is the most popular choice for structured logging in .NET, offering powerful configuration and a rich ecosystem of sinks (destinations for your logs like files, databases, and logging services). You configure it usually in Program.cs or Startup.cs:
// In Program.cs (for .NET 6+ minimal APIs)
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {SourceContext} - {Message}{NewLine}{Exception}")
.WriteTo.Seq("http://localhost:5341") // Example Seq sink
.CreateLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog(); // Integrate Serilog with the host
// ... rest of your builder configuration ...
var app = builder.Build();
// ... app configuration ...
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
The outputTemplate in the Console sink demonstrates how you can still control the human-readable output while the structured data is preserved for other sinks. The Seq sink, for instance, automatically ingests the structured properties.
The real power comes from specifying what data to log. Beyond simple values, you can log complex objects. If you pass an object directly to the log method, Serilog can often serialize it into JSON within the log event, making its properties searchable.
_logger.LogInformation("Order details: {@Order}", order);
This would result in a log entry with an Order property containing the serialized OrderDetails object.
One crucial aspect of structured logging that trips people up is the distinction between the message template and the rendered message. The template, like "Received order processing request for Order ID: {OrderId}", is what Serilog uses to identify the structure and extract properties. The rendered message, "Received order processing request for Order ID: 12345", is the final string you’d see in a plain text log. When you search structured logs, you’re searching the properties (like OrderId: 12345), not just substrings within the rendered message. This makes queries precise and performant.
The next logical step after mastering structured logging is understanding how to manage log levels effectively and implement correlation IDs to trace requests across multiple services.