Emit logs on-demand with log buffering

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:

  1. Your code generates logs normally
  2. Matching logs are captured in memory rather than being emitted immediately
  3. 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:

  1. Global buffering: Buffers logs across the entire application
  2. 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 the AutoFlushDuration 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.

Previous Article

How to filter C++ Build Insights by project

Next Article

Restricting PAT Creation in Azure DevOps Is Now in Preview

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *