Structured Logging with OpenTelemetry in .NET

Plain text logs are easy to write but painful to query. When your application writes "Order 12345 created for customer 67890", finding all orders for a specific customer requires regex. Structured logging solves this by treating log data as key-value pairs, and OpenTelemetry takes it further by correlating logs with distributed traces.

Structured Logging in .NET

.NET's ILogger has supported structured logging since its inception. The message template syntax uses named placeholders:

Example.cs
_logger.LogInformation("Order {OrderId} created for customer {CustomerId}",
    order.Id, customer.Id);

This produces a log record with two structured properties: OrderId = 12345 and CustomerId = 67890. The formatted message is still human-readable, but the individual values are queryable.

The key rule: never use string interpolation with ILogger. This destroys the structure:

Example.cs
// Wrong — loses structure
_logger.LogInformation($"Order {order.Id} created for customer {customer.Id}");

// Correct — preserves structure
_logger.LogInformation("Order {OrderId} created for customer {CustomerId}",
    order.Id, customer.Id);

Adding OpenTelemetry Logging

Aspire configures OpenTelemetry logging in the ServiceDefaults project:

Example.cs
builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
});

With this configuration, every ILogger call produces an OpenTelemetry log record that includes:

Correlation with Traces

The most powerful aspect of OpenTelemetry logging is automatic trace correlation. When you write a log inside a request handler, the log record includes the trace ID of the current request:

Example.cs
app.MapPost("/orders", async (CreateOrderRequest request,
    OrderService orderService, ILogger<Program> logger) =>
{
    logger.LogInformation("Creating order for customer {CustomerId}",
        request.CustomerId);

    var order = await orderService.CreateAsync(request);

    logger.LogInformation("Order {OrderId} created with total {Total}",
        order.Id, order.Total);

    return Results.Created($"/orders/{order.Id}", order);
});

Both log entries share the same trace ID. In the Aspire dashboard, you can view a trace and see the associated log entries inline. In production, you can search your logging backend for a trace ID and see every log entry across every service for that request.

Log Scopes

Scopes add contextual properties to every log entry within a block:

Example.cs
public async Task ProcessOrderAsync(Order order)
{
    using (_logger.BeginScope(new Dictionary<string, object>
    {
        ["OrderId"] = order.Id,
        ["CustomerId"] = order.CustomerId
    }))
    {
        _logger.LogInformation("Starting order processing");
        await ValidateAsync(order);
        _logger.LogInformation("Validation passed");
        await ChargePaymentAsync(order);
        _logger.LogInformation("Payment charged");
        await FulfillAsync(order);
        _logger.LogInformation("Order fulfilled");
    }
}

Every log entry inside the scope automatically includes OrderId and CustomerId, even though they are not part of each message template. This eliminates the need to repeat context in every log call.

High-Performance Logging

For hot paths where logging overhead matters, use the LoggerMessage source generator:

Example.cs
public static partial class LogMessages
{
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Order {OrderId} created for customer {CustomerId}")]
    public static partial void OrderCreated(
        this ILogger logger, int orderId, int customerId);

    [LoggerMessage(Level = LogLevel.Warning,
        Message = "Inventory low for product {ProductId}: {Remaining} remaining")]
    public static partial void InventoryLow(
        this ILogger logger, int productId, int remaining);

    [LoggerMessage(Level = LogLevel.Error,
        Message = "Payment failed for order {OrderId}")]
    public static partial void PaymentFailed(
        this ILogger logger, int orderId, Exception exception);
}

Usage:

Example.cs
_logger.OrderCreated(order.Id, customer.Id);
_logger.InventoryLow(product.Id, stock.Remaining);
_logger.PaymentFailed(order.Id, ex);

The source generator eliminates boxing allocations and avoids string formatting when the log level is disabled. The structured properties are still preserved.

Exporting Logs

During development, the OTLP exporter sends logs to the Aspire dashboard. In production, configure the exporter to send to your logging backend:

data.json
{
  "OTEL_EXPORTER_OTLP_ENDPOINT": "https://collector.mycompany.com:4317"
}

Alternatively, you can keep using a traditional logging provider (Serilog, NLog) alongside OpenTelemetry. The structured properties and trace correlation work with any provider that respects ILogger's message template semantics.

Filtering and Levels

Configure log levels per category as usual:

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Setting EF Core's command logging to Information lets you see SQL queries in your traces — useful during development, but too noisy for production.

Structured logging with OpenTelemetry transforms logs from a debugging afterthought into a first-class observability signal. Combined with traces and metrics, it gives you complete visibility into your distributed application.