Supercharge Your .NET Logging Performance with LoggerMessage Attribute
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:
- String interpolation and formatting overhead
- Boxing of value types
- Delegate allocations
- 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:
- Creates pre-compiled delegates (
__LogProcessingOrderCallback, etc.) usingLoggerMessage.Define<T> - Implements the IsEnabled check for each method to avoid unnecessary work
- Handles exceptions properly by passing them as the last parameter to the callback
- Uses fully qualified type names to avoid namespace conflicts
- 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
LoggerMessageattributes - 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 /* ... */);
2. Group Related Logging Methods
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:
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.
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.
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.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.