Managing logs in production environments involves tricky trade-offs. You want detailed logs when things go wrong, but you don’t want to pay storage costs for logs that never get examined. While log sampling helps reduce log volume by emitting only a percentage of logs, sometimes you need a different approach.
Enter log buffering in .NET 9 – an innovative capability that temporarily stores logs in memory and lets you decide later whether to emit them. Rather than making an immediate emit-or-discard decision, buffering gives you contextual control over which logs are preserved based on actual runtime outcomes.
The concept: Logs on standby
Log buffering introduces a new flow pattern:
- Your code generates logs normally
- Matching logs are captured in memory rather than being emitted immediately
- Later, based on specific conditions, you decide whether to:
- Flush the buffer and emit all captured logs
- Let the buffer expire, effectively discarding the logs
This approach is particularly valuable when you only need logs in error scenarios. For example, you might buffer detailed logs for a transaction, emitting them only if the transaction fails but discarding them if it succeeds.
flowchart TD
A[Log record created] --> B{Match buffer rule?}
B -->|Yes| C[Store in circular buffer]
B -->|No| D[Emit Normally]
C --> E{Error or Event?}
E -->|Yes| F[Flush Buffer]
E -->|No| G[Logs in circular buffer expire]
F --> H[All buffered logs emitted]
G --> I[Expired logs discarded]
Two powerful buffering strategies
.NET 9 provides two complementary buffering strategies:
- Global buffering: Buffers logs across the entire application
- Per-request buffering: Buffers logs independently for each HTTP request (ASP.NET Core only)
Let’s explore how to implement each approach.
Global buffering: Application-wide control
Global buffering stores logs in circular buffers shared across your application. This approach is perfect for buffering logs related to long-running processes or background tasks.
For basic scenarios, enable buffering with just one line:
builder.Logging.AddGlobalLogBuffer(LogLevel.Information);
This configuration buffers all Information-level logs and below.
For more sophisticated scenarios, define specific buffering rules:
builder.Logging.AddGlobalBuffer(options =>
{
// Buffer all logs with Information level from categories starting with "PaymentService"
options.Rules.Add(
new LogBufferingFilterRule(
categoryName: "PaymentService",
logLevel: LogLevel.Information));
options.MaxBufferSizeInBytes = 100 * 1024 * 1024; // 100MB
options.MaxLogRecordSizeInBytes = 50 * 1024; // 50KB
options.AutoFlushDuration = TimeSpan.FromSeconds(30);
});
MaxBufferSizeInBytes
prevents the buffer from growing indefinitely. When the buffer reaches this size, older logs are discarded to make room for new ones.MaxLogRecordSizeInBytes
limits the size of individual log records, ensuring that excessively large logs do not consume too much memory – they are simply emitted normally.AutoFlushDuration
controls for how long the log buffering stays disabled after the flush operation has been triggered. This comes in handy when you trigger the buffer flush operation when an error occurs and you want to investigate the logs carefully and, of course, you want to see all logs available, you don’t need them to be buffered or discarded. With theAutoFlushDuration
set to 30 seconds, the buffering will remain disabled for that duration, allowing your application to emit all logs normally.
Flushing the global buffer
To emit buffered logs, inject the GlobalLogBuffer
class and call its Flush()
method:
public class PaymentService
{
private readonly ILogger<PaymentService> _logger;
private readonly GlobalLogBuffer _logBuffer;
public PaymentService(ILogger<PaymentService> logger, GlobalLogBuffer logBuffer)
{
_logger = logger;
_logBuffer = logBuffer;
}
public async Task ProcessPayment(PaymentRequest request)
{
try
{
_logger.LogInformation("Starting payment processing for {TransactionId}", request.TransactionId);
// Process payment...
_logger.LogInformation("Payment processed successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment processing failed");
// Flush buffer on error to emit all detailed logs
_logBuffer.Flush();
throw;
}
}
}
In this example, detailed logs about the payment process are only emitted if an exception occurs.
Per-request buffering: Request-scoped control
For web applications, per-request buffering offers even more granular control by maintaining separate buffers for each HTTP request. This is ideal for tracking the full context of individual user interactions.
Enable per-request buffering in your ASP.NET Core application:
builder.Services.AddPerIncomingRequestBuffer(options =>
{
// Buffer Information logs from API controllers
options.Rules.Add(
new LogBufferingFilterRule(
categoryPrefix: "MyApp.Controllers",
logLevel: LogLevel.Information));
// Set auto-flush duration
options.AutoFlushDuration = TimeSpan.FromSeconds(5);
});
The key difference from the global buffering here is that when the HTTP request ends, the underlying buffer is disposed and all its logs are discarded.
Flushing the per-request buffer
To emit buffered logs for a specific request, inject the PerRequestLogBuffer
class:
[ApiController]
[Route("[controller]")]
public class OrderController : ControllerBase
{
private readonly ILogger<OrderController> _logger;
private readonly PerRequestLogBuffer _requestBuffer;
public OrderController(ILogger<OrderController> logger, PerRequestLogBuffer requestBuffer)
{
_logger = logger;
_requestBuffer = requestBuffer;
}
[HttpPost]
public IActionResult CreateOrder(OrderRequest request)
{
try
{
_logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
// Order processing logic...
_logger.LogInformation("Order created successfully with ID {OrderId}", orderId);
return Ok(new { OrderId = orderId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create order");
// Flush the buffer to emit all logs for this request
_requestBuffer.Flush();
return StatusCode(500, "Order creation failed");
}
}
}
Tip
Flushing the per-request buffer also flushes the global buffer, ensuring all relevant logs are emitted when an error occurs.
Dynamic configuration updates
Log buffering supports runtime configuration updates. If you’re using a configuration provider that supports reloads (such as the File Configuration Provider), you can update buffering rules without restarting your application.
This is especially valuable in production scenarios where you might need to temporarily increase logging detail to diagnose an issue, then revert back to normal logging levels once the issue is resolved.
Best practices
1. Buffer strategically
Buffer detailed logs at Information level and below, but let Error and Critical logs emit immediately. This ensures important issues are always visible while detailed context is available only when needed.
2. Create explicit flush triggers
Identify the specific conditions where log context is valuable and add explicit flush calls:
- On exceptions or errors
- When operations exceed expected durations
- When suspicious patterns are detected
3. Combine with log sampling for maximum efficiency
For high-volume applications, use both buffering and sampling together:
- Sample routine logs to reduce overall volume
- Buffer detailed logs for contextual flushing when needed
Limitations to be aware of
While log buffering is powerful, it has a few limitations:
- It’s available only in .NET 9 and later
- The exact order of logs may not be preserved (timestamps are preserved)
- Log scopes are not supported with buffered logs
- Some advanced log record properties (like ActivitySpanId) are not preserved
Performance considerations
Log buffering makes a memory-for-storage tradeoff:
- Memory usage: Buffered logs consume memory until they’re either flushed or discarded
- Storage savings: By only emitting logs in specific scenarios, you can drastically reduce log storage costs
For large distributed applications, the memory overhead is minimal compared to the potential storage savings.
Summary
Log buffering gives you unprecedented control over which logs are actually emitted to storage. Rather than making upfront decisions about logging levels, you can instrument your code thoroughly, buffer the logs, and decide at runtime which execution paths deserve detailed logging.
Whether you use global buffering, per-request buffering, or both, this feature can dramatically reduce your logging costs while improving the quality of diagnostic information available when problems occur.
References
The post Emit logs on-demand with log buffering appeared first on .NET Blog.