Logging is essential for monitoring and debugging applications, but it can become a performance bottleneck when not implemented efficiently. The LoggerMessage attribute, introduced in .NET 6, offers a powerful solution that combines high performance with developer-friendly APIs. In this article, we'll explore how this attribute reduces logging overhead, handles exceptions gracefully, and leverages source generators to create optimized code.

The Problem with Traditional Logging

Traditional logging approaches often suffer from performance issues due to:

  1. String interpolation and formatting overhead
  2. Boxing of value types
  3. Delegate allocations
  4. Reflection-based parameter handling

Consider this common logging pattern:

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task ProcessOrderAsync(int orderId, string customerName, decimal amount)
    {
        _logger.LogInformation("Processing order {OrderId} for customer {CustomerName} with amount {Amount}", 
                              orderId, customerName, amount);

        try
        {
            // Process order logic
            await ProcessPaymentAsync(amount);
            _logger.LogInformation("Order {OrderId} processed successfully", orderId);
        }
        catch (PaymentException ex)
        {
            _logger.LogError(ex, "Failed to process payment for order {OrderId}", orderId);
            throw;
        }
    }
}

While functional, this approach has hidden performance costs that become significant under high load.

flowchart TD
    A["Traditional Logging Call"] --> B["String Interpolation"]
    B --> C["Boxing Value Types"]
    C --> D["Delegate Allocation"]
    D --> E["Runtime Formatting"]
    E --> F["Log Output"]

    G["LoggerMessage Call"] --> H["Pre-check Log Level"]
    H --> I{"Enabled?"}
    I -->|No| J["Skip - Zero Cost"]
    I -->|Yes| K["Use Pre-compiled Struct"]
    K --> L["Direct Parameter Access"]
    L --> M["Optimized Formatting"]
    M --> F

    style A fill:#ffcccc
    style G fill:#ccffcc
    style J fill:#e1f5fe
    style F fill:#fff3e0

Enter LoggerMessage Attribute

The LoggerMessage attribute transforms logging from a runtime operation into a compile-time optimized process. Here's how to refactor the above example to always use extension-method syntax (the ILogger is always the first parameter):

using Microsoft.Extensions.Logging;

namespace LoggingMessageDemo;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task ProcessOrderAsync(int orderId, string customerName, decimal amount)
    {
        _logger.LogProcessingOrder(orderId, customerName, amount);

        try
        {
            await Task.Delay(1000);
            _logger.LogOrderProcessed(orderId);
        }
        catch (PaymentException ex)
        {
            _logger.LogPaymentFailed(ex, orderId);
            throw;
        }
    }
}

public static partial class Log
{
    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Processing order {OrderId} for customer {CustomerName} with amount {Amount}")]
    public static partial void LogProcessingOrder(this ILogger logger, int orderId, string customerName, decimal amount);

    [LoggerMessage(
        EventId = 1002,
        Level = LogLevel.Information,
        Message = "Order {OrderId} processed successfully")]
    public static partial void LogOrderProcessed(this ILogger logger, int orderId);

    [LoggerMessage(
        EventId = 1003,
        Level = LogLevel.Error,
        Message = "Failed to process payment for order {OrderId}")]
    public static partial void LogPaymentFailed(this ILogger logger, Exception ex, int orderId);
}

Generated Code Behind the Scenes

When you compile the above code, the source generator produces highly optimized implementations. Here's what the compiler generates for our OrderService example:

// <auto-generated/>
#nullable enable

namespace LoggingMessageDemo
{
    partial class Log
    {
        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "9.0.12.41916")]
        private static readonly global::System.Action<global::Microsoft.Extensions.Logging.ILogger, global::System.Int32, global::System.String, global::System.Decimal, global::System.Exception?> __LogProcessingOrderCallback =
            global::Microsoft.Extensions.Logging.LoggerMessage.Define<global::System.Int32, global::System.String, global::System.Decimal>(global::Microsoft.Extensions.Logging.LogLevel.Information, new global::Microsoft.Extensions.Logging.EventId(1001, nameof(LogProcessingOrder)), "Processing order {OrderId} for customer {CustomerName} with amount {Amount}", new global::Microsoft.Extensions.Logging.LogDefineOptions() { SkipEnabledCheck = true }); 

        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "9.0.12.41916")]
        public static partial void LogProcessingOrder(this global::Microsoft.Extensions.Logging.ILogger logger, global::System.Int32 orderId, global::System.String customerName, global::System.Decimal amount)
        {
            if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
            {
                __LogProcessingOrderCallback(logger, orderId, customerName, amount, null);
            }
        }

        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "9.0.12.41916")]
        private static readonly global::System.Action<global::Microsoft.Extensions.Logging.ILogger, global::System.Int32, global::System.Exception?> __LogOrderProcessedCallback =
            global::Microsoft.Extensions.Logging.LoggerMessage.Define<global::System.Int32>(global::Microsoft.Extensions.Logging.LogLevel.Information, new global::Microsoft.Extensions.Logging.EventId(1002, nameof(LogOrderProcessed)), "Order {OrderId} processed successfully", new global::Microsoft.Extensions.Logging.LogDefineOptions() { SkipEnabledCheck = true }); 

        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "9.0.12.41916")]
        public static partial void LogOrderProcessed(this global::Microsoft.Extensions.Logging.ILogger logger, global::System.Int32 orderId)
        {
            if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
            {
                __LogOrderProcessedCallback(logger, orderId, null);
            }
        }

        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "9.0.12.41916")]
        private static readonly global::System.Action<global::Microsoft.Extensions.Logging.ILogger, global::System.Int32, global::System.Exception?> __LogPaymentFailedCallback =
            global::Microsoft.Extensions.Logging.LoggerMessage.Define<global::System.Int32>(global::Microsoft.Extensions.Logging.LogLevel.Error, new global::Microsoft.Extensions.Logging.EventId(1003, nameof(LogPaymentFailed)), "Failed to process payment for order {OrderId}", new global::Microsoft.Extensions.Logging.LogDefineOptions() { SkipEnabledCheck = true }); 

        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "9.0.12.41916")]
        public static partial void LogPaymentFailed(this global::Microsoft.Extensions.Logging.ILogger logger, global::System.Exception ex, global::System.Int32 orderId)
        {
            if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Error))
            {
                __LogPaymentFailedCallback(logger, orderId, ex);
            }
        }
    }
}

Notice how the generated code:

  1. Creates pre-compiled delegates (__LogProcessingOrderCallback, etc.) using LoggerMessage.Define<T>
  2. Implements the IsEnabled check for each method to avoid unnecessary work
  3. Handles exceptions properly by passing them as the last parameter to the callback
  4. Uses fully qualified type names to avoid namespace conflicts
  5. Includes metadata attributes for tooling and debugging support

This generated code eliminates the runtime overhead of string formatting, parameter boxing, and delegate allocation that traditional logging approaches suffer from.

Performance Benefits Explained

The LoggerMessage attribute delivers several key performance improvements:

1. Eliminated String Formatting Overhead

Traditional logging performs string formatting at runtime, even when the log level is disabled. LoggerMessage pre-checks the log level and only formats when necessary:

// Generated code (simplified)
private void LogProcessingOrder(int orderId, string customerName, decimal amount)
{
    if (_logger.IsEnabled(LogLevel.Information))
    {
        _logger.Log(LogLevel.Information, new EventId(1001), 
                   new __LogProcessingOrderStruct(orderId, customerName, amount), 
                   null, __LogProcessingOrderCallback);
    }
}

2. Reduced Allocations

The source generator creates optimized structs that implement IReadOnlyList<KeyValuePair<string, object>>, minimizing heap allocations:

// Generated struct for parameters
private readonly struct __LogProcessingOrderStruct : IReadOnlyList<KeyValuePair<string, object>>
{
    private readonly int _orderId;
    private readonly string _customerName;
    private readonly decimal _amount;

    public __LogProcessingOrderStruct(int orderId, string customerName, decimal amount)
    {
        _orderId = orderId;
        _customerName = customerName;
        _amount = amount;
    }

    public int Count => 3;
    // Implementation details...
}

3. Pre-compiled Format Strings

Message templates are parsed at compile time, eliminating runtime parsing overhead.

Exception Handling: No Compromises

One of the most elegant aspects of LoggerMessage is how it handles exceptions without sacrificing performance. The attribute supports exception parameters seamlessly. Always use extension-method syntax so the ILogger instance is explicit:

public static partial class Log
{
    [LoggerMessage(
        EventId = 2001,
        Level = LogLevel.Error,
        Message = "Database connection failed for user {UserId}")]
    public static partial void LogDatabaseConnectionFailed(this ILogger logger, Exception ex, int userId);
}

// Usage
try
{
    await ConnectToDatabaseAsync(userId);
}
catch (SqlException ex)
{
    logger.LogDatabaseConnectionFailed(ex, userId);
    throw;
}

The generated code properly handles the exception parameter:

// Generated method
private void LogDatabaseConnectionFailed(Exception ex, int userId)
{
    if (_logger.IsEnabled(LogLevel.Error))
    {
        _logger.Log(LogLevel.Error, new EventId(2001), 
                   new __LogDatabaseConnectionFailedStruct(userId), 
                   ex, __LogDatabaseConnectionFailedCallback);
    }
}

Key benefits for exception handling:

  • Full stack trace preservation
  • Structured logging compatibility
  • Zero performance penalty when logging is disabled
  • Proper exception correlation in log aggregation systems

Source Generator Magic Under the Hood

The LoggerMessage attribute leverages .NET's source generators to create optimized code at compile time. Here's what happens:

sequenceDiagram
    participant Dev as Developer
    participant Compiler as C# Compiler
    participant SG as Source Generator
    participant Output as Generated Code

    Dev->>Compiler: Write partial method with [LoggerMessage]
    Compiler->>SG: Analyze syntax tree
    SG->>SG: Parse message template
    SG->>SG: Validate parameters
    SG->>SG: Generate parameter struct
    SG->>SG: Generate formatter callback
    SG->>SG: Generate logging method
    SG->>Output: Emit optimized code
    Output->>Compiler: Add to compilation
    Compiler->>Dev: Compiled assembly with optimizations

1. Compile-Time Analysis

During compilation, the source generator:

  • Scans for LoggerMessage attributes
  • Validates message templates
  • Analyzes parameter types and names
  • Generates type-safe, optimized implementations

2. Code Generation Process

The generator creates several components for each logging method:

// 1. Parameter struct
private readonly struct __LogProcessingOrderStruct : IReadOnlyList<KeyValuePair<string, object>>
{
    // Efficient parameter storage and enumeration
}

// 2. Formatter callback
private static readonly Func<__LogProcessingOrderStruct, Exception, string> __LogProcessingOrderCallback = 
    (state, exception) => $"Processing order {state._orderId} for customer {state._customerName} with amount {state._amount}";

// 3. The actual logging method
private partial void LogProcessingOrder(int orderId, string customerName, decimal amount)
{
    // Optimized implementation
}

3. Template Parsing and Validation

The generator performs compile-time validation:

// This would generate a compile error
[LoggerMessage(Message = "Invalid template {MissingParam}")]
private partial void LogSomething(int actualParam); // Compile error: MissingParam not found

It is however allowed to change the casing of the placeholder in the logging template.

// This will not generate a compile error and is good practice due to CA1727: Use PascalCase for named placeholders
[LoggerMessage(Message = "Invalid template {ActualParam}")]
private partial void LogSomething(int actualParam); 

4. Runtime Execution Flow

Here's how the generated code executes at runtime:

flowchart TD
    A["Application calls LogProcessingOrder()"] --> B["Check _logger.IsEnabled()"]
    B --> C{"Log Level Enabled?"}
    C -->|No| D["Return immediately<br>(Zero allocations)"]
    C -->|Yes| E["Create parameter struct"]
    E --> F["Call _logger.Log() with:<br>- EventId<br>- LogLevel<br>- State struct<br>- Exception<br>- Formatter callback"]
    F --> G["Logger processes structured data"]
    G --> H["Format message using callback"]
    H --> I["Write to configured sinks"]

    style D fill:#e8f5e8
    style E fill:#fff3e0
    style I fill:#e3f2fd

Advanced Usage Patterns

Custom Event IDs and Names

public static partial class Log
{
    [LoggerMessage(
        EventId = 5001,
        EventName = "UserLoginAttempt",
        Level = LogLevel.Information,
        Message = "User {Username} attempted login from {IpAddress}")]
    public static partial void LogUserLoginAttempt(this ILogger logger, string username, string ipAddress);
}

Conditional Logging with Skip Enabled Check

public static partial class Log
{
    [LoggerMessage(
        EventId = 6001,
        Level = LogLevel.Debug,
        Message = "Cache hit for key {CacheKey}",
        SkipEnabledCheck = true)] // Skip the IsEnabled check for ultra-hot paths
    public static partial void LogCacheHit(this ILogger logger, string cacheKey);
}

Generic Type Support

public partial class GenericService<T>
{
    public void ProcessItem(ILogger logger, T item, int id)
    {
        logger.LogProcessingItem(typeof(T).Name, id);
        // Process logic
    }
}

public static partial class Log
{
    [LoggerMessage(
        EventId = 7001,
        Level = LogLevel.Information,
        Message = "Processing item of type {ItemType} with id {ItemId}")]
    public static partial void LogProcessingItem(this ILogger logger, string itemType, object itemId);
}

Performance Benchmarks

xychart-beta
    title "Performance Comparison: Traditional vs LoggerMessage"
    x-axis ["Allocations (KB)", "Execution Time (ns)", "GC Pressure"]
    y-axis "Relative Performance" 0 --> 100
    bar [85, 75, 90]
    bar [15, 25, 10]

Light: Traditional Logging, Dark: LoggerMessage (lower is better)

Here's a simple benchmark comparing traditional logging vs LoggerMessage. The benchmark uses the extension-method syntax for the generated logger methods:

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net70)]
public class LoggingBenchmark
{
    private readonly ILogger<LoggingBenchmark> _logger;

    public LoggingBenchmark()
    {
        var serviceProvider = new ServiceCollection()
            .AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Warning))
            .BuildServiceProvider();
        _logger = serviceProvider.GetRequiredService<ILogger<LoggingBenchmark>>();
    }

    [Benchmark]
    public void TraditionalLogging()
    {
        _logger.LogInformation("Processing order {OrderId} for {CustomerName}", 12345, "John Doe");
    }

    [Benchmark]
    public void LoggerMessageLogging()
    {
        _logger.LogProcessingOrder(12345, "John Doe");
    }
}

public static partial class Log
{
    [LoggerMessage(EventId = 1, Level = LogLevel.Information, 
                   Message = "Processing order {OrderId} for {CustomerName}")]
    public static partial void LogProcessingOrder(this ILogger logger, int orderId, string customerName);
}

Typical results show:

  • 50-80% reduction in allocations
  • 2-3x performance improvement
  • Consistent performance regardless of parameter count

Best Practices and Recommendations

mindmap
  root((LoggerMessage <br> Best Practices))
    Event Management
      Consistent Event IDs
      Meaningful Event Names
      Logical Grouping
    Performance
      Use SkipEnabledCheck sparingly
      Group related methods
      Avoid complex message templates
    Code Organization
      Partial classes
      Static event ID constants
      Region grouping
    Exception Handling
      Always include Exception parameter
      Preserve stack traces
      Use structured parameters
    Message Design
      Template reusability
      Search-friendly format
      Consistent parameter naming

1. Use Consistent Event IDs

public static class EventIds
{
    public const int OrderProcessing = 1001;
    public const int OrderCompleted = 1002;
    public const int PaymentFailed = 1003;
}

[LoggerMessage(EventId = EventIds.OrderProcessing, /* ... */)]
public static partial void LogOrderProcessing(this ILogger logger /* ... */);
public static partial class OrderLog
{
    // Group all order-related logging methods together
    [LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Order {OrderId} created")]
    public static partial void LogOrderCreated(this ILogger logger, int orderId);

    [LoggerMessage(EventId = 1002, Level = LogLevel.Information, Message = "Order {OrderId} validated")]
    public static partial void LogOrderValidated(this ILogger logger, int orderId);

    [LoggerMessage(EventId = 1003, Level = LogLevel.Error, Message = "Order {OrderId} validation failed")]
    public static partial void LogOrderValidationFailed(this ILogger logger, Exception ex, int orderId);
}

3. Consider Message Template Reusability

Design message templates that work well with log aggregation and searching:

// Good: Structured and searchable
[LoggerMessage(Message = "Operation {OperationType} completed for entity {EntityId} in {Duration}ms")]

// Avoid: Hard to search and analyze
[LoggerMessage(Message = "The {OperationType} operation took {Duration}ms to complete for entity {EntityId}")]

The second example should be avoided because:

  1. Search Difficulty: When searching logs for specific operations, you'd need to search for "The CreateOrder operation took" instead of simply "Operation CreateOrder". The variable text placement makes pattern matching harder.

  2. Log Aggregation Issues: Log analysis tools rely on consistent message patterns. Having dynamic content at the beginning breaks pattern recognition, making it difficult to group related log entries.

  3. Parameter Ordering: The scattered parameter placement ({OperationType}...{Duration}...{EntityId}) makes it harder to quickly scan logs and identify key information. The structured approach keeps related data together.

  4. Query Performance: Many log aggregation systems (like Elasticsearch, Splunk) perform better when the static portion of the message comes first, allowing for more efficient indexing and searching.

Conclusion

The LoggerMessage attribute represents a significant leap forward in .NET logging performance. By leveraging compile-time code generation, it eliminates runtime overhead while maintaining the flexibility and safety of structured logging. The seamless exception handling ensures you don't have to choose between performance and debuggability.

Key takeaways:

  • Dramatic performance improvements with minimal code changes
  • Excellent exception handling without performance penalties
  • Compile-time safety through source generator validation
  • Zero-allocation logging when log levels are disabled
  • Maintains full compatibility with existing logging infrastructure

As .NET applications continue to scale and performance becomes increasingly critical, adopting LoggerMessage attribute is a simple yet powerful optimization that every .NET developer should consider. The combination of performance benefits, type safety, and developer experience makes it an essential tool in the modern .NET developer's toolkit.

Start refactoring your high-frequency logging calls today, and watch your application's performance soar while maintaining the observability your applications need.