Skip to content

[API Proposal]: Introduce logging sampling and buffering #5123

@evgenyfedorov2

Description

@evgenyfedorov2

API Proposal

Logging sampling

LoggerSampler.cs:

namespace Microsoft.Extensions.Logging;

 /// <summary>
 /// Controls the number of samples of log records collected and sent to the backend.
 /// </summary>
public abstract class LoggingSampler
{
    /// <summary>
    /// Makes a sampling decision for the provided <paramref name="logEntry"/>.
    /// </summary>
    /// <param name="logEntry">The log entry used to make the sampling decision for.</param>
    /// <typeparam name="TState">The type of the log entry state.</typeparam>
    /// <returns><see langword="true" /> if the log record should be sampled; otherwise, <see langword="false" />.</returns>
    public abstract bool ShouldSample<TState>(in LogEntry<TState> logEntry);
}

SamplingLoggerBuilderExtensions.cs:

namespace Microsoft.Extensions.Logging;

/// <summary>
/// Extensions for configuring logging sampling.
/// </summary>
public static class SamplingLoggerBuilderExtensions
{
    /// <summary>
    /// Adds Trace-based logging sampler to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The dependency injection container to add logging to.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>Sampling decisions for logs match exactly the sampling decisions for the underlying <see cref="System.Diagnostics.Activity"/>.
    /// You may want to configure Tracing Sampling separately as part of OpenTelemetry .NET.</remarks>
    public static ILoggingBuilder AddTraceBasedSampler(this ILoggingBuilder builder);

    /// <summary>
    /// Adds Probabilistic logging sampler to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The dependency injection container to add logging to.</param>
    /// <param name="configuration">The <see cref="IConfiguration" /> to add.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be sampled according to the configured probability.
    /// Higher the probability value, higher is the probability of a given log record to be sampled in.
    /// </remarks>
    public static ILoggingBuilder AddProbabilisticSampler(this ILoggingBuilder builder, IConfiguration configuration);

    /// <summary>
    /// Adds Probabilistic logging sampler to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The dependency injection container to add logging to.</param>
    /// <param name="configure">The <see cref="ProbabilisticSamplerOptions"/> configuration delegate.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be sampled according to the configured probability.
    /// Higher the probability value, higher is the probability of a given log record to be sampled in.
    /// </remarks>
    public static ILoggingBuilder AddProbabilisticSampler(this ILoggingBuilder builder, Action<ProbabilisticSamplerOptions> configure)

    /// <summary>
    /// Adds Probabilistic logging sampler to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The dependency injection container to add logging to.</param>
    /// <param name="probability">Probability from 0.0 to 1.0.</param>
    /// <param name="level">The log level (and below) to apply the sampler to.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be sampled according to the configured <paramref name="probability"/>.
    /// Higher the probability value, higher is the probability of a given log record to be sampled in.
    /// </remarks>
    public static ILoggingBuilder AddProbabilisticSampler(this ILoggingBuilder builder, double probability, LogLevel? level = null);

    /// <summary>
    /// Adds a logging sampler type to the logging infrastructure.
    /// </summary>
    /// <typeparam name="T">Logging sampler type.</typeparam>
    /// <param name="builder">The dependency injection container to add logging to.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    public static ILoggingBuilder AddSampler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this ILoggingBuilder builder)
        where T : LoggingSampler

    /// <summary>
    /// Adds a logging sampler instance to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The dependency injection container to add logging to.</param>
    /// <param name="sampler">The sampler instance to add.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="sampler"/> is <see langword="null"/>.</exception>
    public static ILoggingBuilder AddSampler(this ILoggingBuilder builder, LoggingSampler sampler);
}

ProbabilisticSamplerOptions.cs:

namespace Microsoft.Extensions.Diagnostics.Sampling;

/// <summary>
/// The options for the Probabilistic sampler.
/// </summary>
public class ProbabilisticSamplerOptions
{
    /// <summary>
    /// Gets or sets the collection of <see cref="ProbabilisticSamplerFilterRule"/> used for filtering log messages.
    /// </summary>
    public IList<ProbabilisticSamplerFilterRule> Rules { get; set; } = [];
}

ProbabilisticSamplerFilterRule.cs:

namespace Microsoft.Extensions.Diagnostics.Sampling;

/// <summary>
/// Defines a rule used to filter log messages for purposes of sampling.
/// </summary>
public class ProbabilisticSamplerFilterRule : ILogSamplingFilterRule
{
    /// <summary>
    /// Initializes a new instance of the <see cref="ProbabilisticSamplerFilterRule"/> class.
    /// </summary>
    /// <param name="probability">The probability for sampling in if this rule applies.</param>
    /// <param name="categoryName">The category name to use in this filter rule.</param>
    /// <param name="logLevel">The <see cref="LogLevel"/> to use in this filter rule.</param>
    /// <param name="eventId">The event ID to use in this filter rule.</param>
    /// <param name="eventName">The event name to use in this filter rule.</param>
    public ProbabilisticSamplerFilterRule(
        double probability,
        string? categoryName = null,
        LogLevel? logLevel = null,
        int? eventId = null,
        string? eventName = null)
    {
        Probability = probability;
        CategoryName = categoryName;
        LogLevel = logLevel;
        EventId = eventId;
        EventName = eventName;
    }

    /// <summary>
    /// Gets the probability for sampling in if this rule applies.
    /// </summary>
    public double Probability { get; }

    /// <inheritdoc/>
    public string? CategoryName { get; }

    /// <inheritdoc/>
    public LogLevel? LogLevel { get; }

    /// <inheritdoc/>
    public int? EventId { get; }

    /// <inheritdoc/>
    public string? EventName { get; }
}

Logging buffering

GlobalBufferingLoggingBuilderExtensions.cs:

namespace Microsoft.Extensions.Logging;

/// <summary>
/// Lets you register log buffering in a dependency injection container.
/// </summary>
public static class GlobalBufferingLoggingBuilderExtensions
{
    /// <summary>
    /// Adds global log buffering to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
    /// <param name="configuration">The <see cref="IConfiguration" /> to add.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be buffered and can optionally be flushed and emitted.
    /// </remarks>
    public static ILoggingBuilder AddGlobalBuffering(this ILoggingBuilder builder, IConfiguration configuration);

    /// <summary>
    /// Adds global log buffering to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
    /// <param name="configure">Configure buffer options.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be buffered and can optionally be flushed and emitted.
    /// </remarks>
    public static ILoggingBuilder AddGlobalBuffering(this ILoggingBuilder builder, Action<GlobalLogBufferingOptions> configure);

    /// <summary>
    /// Adds global log buffering to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
    /// <param name="logLevel">The log level (and below) to apply the buffer to.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be buffered and can optionally be flushed and emitted.
    /// </remarks>
    public static ILoggingBuilder AddGlobalBuffering(this ILoggingBuilder builder, LogLevel? logLevel = null);
}

GlobalLogBufferingOptions.cs:

namespace Microsoft.Extensions.Diagnostics.Buffering;

/// <summary>
/// The options for global log buffering.
/// </summary>
public class GlobalLogBufferingOptions
{
    /// <summary>
    /// Gets or sets the time to suspend the buffering after flushing.
    /// </summary>
    /// <remarks>
    /// Use this to temporarily suspend buffering after a flush, e.g. in case of an incident you may want all logs to be emitted immediately,
    /// so the buffering will be suspended for the <see paramref="SuspendAfterFlushDuration"/> time.
    /// </remarks>
    public TimeSpan SuspendAfterFlushDuration { get; set; } = TimeSpan.FromSeconds(30);

    /// <summary>
    /// Gets or sets the maxiumum size of each individual log record in bytes. If the size of a log record exceeds this limit, it won't be buffered.
    /// </summary>
    public int MaxLogRecordSizeInBytes { get; set; } = 50_000;

    /// <summary>
    /// Gets or sets the maximum size of the buffer in bytes. If adding a new log entry would cause the buffer size to exceed this limit,
    /// the oldest buffered log records will be dropped to make room.
    /// </summary>
    public int MaxBufferSizeInBytes { get; set; } = 500_000_000;

    /// <summary>
    /// Gets or sets the collection of <see cref="LogBufferingFilterRule"/> used for filtering log messages for the purpose of further buffering.
    /// </summary>
    public IList<LogBufferingFilterRule> Rules { get; set; } = [];
}

LogBufferingFilterRule.cs:

namespace Microsoft.Extensions.Diagnostics.Buffering;

/// <summary>
/// Defines a rule used to filter log messages for purposes of further buffering.
/// </summary>
public class LogBufferingFilterRule
{
    /// <summary>
    /// Initializes a new instance of the <see cref="LogBufferingFilterRule"/> class.
    /// </summary>
    /// <param name="categoryName">The category name to use in this filter rule.</param>
    /// <param name="logLevel">The <see cref="LogLevel"/> to use in this filter rule.</param>
    /// <param name="eventId">The event ID to use in this filter rule.</param>
    /// <param name="eventName">The event name to use in this filter rule.</param>
    /// <param name="attributes">The log state attributes to use in this filter tule.</param>
    public LogBufferingFilterRule(
        string? categoryName = null,
        LogLevel? logLevel = null,
        int? eventId = null,
        string? eventName = null,
        IReadOnlyList<KeyValuePair<string, object?>>? attributes = null)
    {
        CategoryName = categoryName;
        LogLevel = logLevel;
        EventId = eventId;
        EventName = eventName;
        Attributes = attributes;
    }

    /// <summary>
    /// Gets the logger category name this rule applies to.
    /// </summary>
    public string? CategoryName { get; }

    /// <summary>
    /// Gets the maximum <see cref="LogLevel"/> of messages this rule applies to.
    /// </summary>
    public LogLevel? LogLevel { get; }

    /// <summary>
    /// Gets the evnet ID of messages where this rule applies to.
    /// </summary>
    public int? EventId { get; }

    /// <summary>
    /// Gets the name of the event this rule applies to.
    /// </summary>
    public string? EventName { get; }

    /// <summary>
    /// Gets the log state attributes of messages where this rules applies to.
    /// </summary>
    public IReadOnlyList<KeyValuePair<string, object?>>? Attributes { get; }
}

LogBuffer.cs:

namespace Microsoft.Extensions.Diagnostics.Buffering;

/// <summary>
/// Buffers logs into circular buffers and drops them after some time if not flushed.
/// </summary>
public abstract class LogBuffer
{
    /// <summary>
    /// Flushes the buffer and emits all buffered logs.
    /// </summary>
    public abstract void Flush();

    /// <summary>
    /// Enqueues a log record in the underlying buffer, if available.
    /// </summary>
    /// <param name="bufferedLogger">A logger capable of logging buffered log records.</param>
    /// <param name="logEntry">A log entry to be buffered.</param>
    /// <typeparam name="TState">Type of the log state in the <paramref name="logEntry"/> instance.</typeparam>
    /// <returns><see langword="true"/> if the log record was buffered; otherwise, <see langword="false"/>.</returns>
    public abstract bool TryEnqueue<TState>(IBufferedLogger bufferedLogger, in LogEntry<TState> logEntry);
}

HttpRequestBufferingLoggingBuilderExtensions.cs:

namespace Microsoft.Extensions.Logging;

/// <summary>
/// Lets you register HTTP request log buffering in a dependency injection container.
/// </summary>
public static class HttpRequestBufferingLoggingBuilderExtensions
{
    /// <summary>
    /// Adds HTTP request log buffering to the logging infrastructure. 
    /// </summary>
    /// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
    /// <param name="configuration">The <see cref="IConfiguration" /> to add.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be buffered in a buffer specific to each HTTP request and can optionally be flushed and emitted during the request lifetime.
    /// </remarks>
    public static ILoggingBuilder AddHttpRequestBuffering(this ILoggingBuilder builder, IConfiguration configuration);

    /// <summary>
    /// Adds HTTP request log buffering to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
    /// <param name="configure">The buffer configuration delegate.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be buffered in a buffer specific to each HTTP request and can optionally be flushed and emitted during the request lifetime.
    /// </remarks>
    public static ILoggingBuilder AddHttpRequestBuffering(this ILoggingBuilder builder, Action<HttpRequestLogBufferingOptions> configure);

    /// <summary>
    /// Adds HTTP request log buffering to the logging infrastructure.
    /// </summary>
    /// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
    /// <param name="logLevel">The log level (and below) to apply the buffer to.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Matched logs will be buffered in a buffer specific to each HTTP request and can optionally be flushed and emitted during the request lifetime.
    /// </remarks>
    public static ILoggingBuilder AddHttpRequestBuffering(this ILoggingBuilder builder, LogLevel? logLevel = null);
}

HttpRequestLogBufferingOptions.cs:

namespace Microsoft.AspNetCore.Diagnostics.Buffering;

/// <summary>
/// The options for HTTP request log buffering.
/// </summary>
public class HttpRequestLogBufferingOptions
{
    /// <summary>
    /// Gets or sets the size in bytes of the buffer for a request. If the buffer size exceeds this limit, the oldest buffered log records will be dropped.
    /// </summary>
    public int MaxPerRequestBufferSizeInBytes { get; set; } = 5_000_000;

    /// <summary>
    /// Gets or sets the collection of <see cref="LogBufferingFilterRule"/> used for filtering log messages for the purpose of further buffering.
    /// </summary>
    public IList<LogBufferingFilterRule> Rules { get; set; } = [];
}

HttpRequestLogBuffer.cs:

namespace Microsoft.AspNetCore.Diagnostics.Buffering;

/// <summary>
/// Buffers HTTP request logs into circular buffers and drops them after some time if not flushed.
/// </summary>
public abstract class HttpRequestLogBuffer : LogBuffer
{
    /// <summary>
    /// Flushes buffers and emits buffered logs for the current HTTP request.
    /// </summary>
    public abstract void FlushCurrentRequestLogs();
}

API Usage

// SAMPLING:
// Enable a sampler using one of the options below:

// 1. sample 10% of Information level logs and below using a built-in probabilistic sampler:
loggingBuilder.AddProbabilisticSampler(0.1, LogLevel.Information);

// 2. or create and register your own sampler:
loggingBuilder.AddSampler<MyCustomSampler>();

// 3. or, in case you use OpenTelemetry Tracing Sampling,
// just apply same sampling decision to logs which is already made to the underlying Activity:
loggingBuilder.AddTraceBasedSampler();

// BUFFERING:

// Enable buffering using one of the options below:
// 1. If you have a non-ASP.NET Core app, and would like to buffer logs of the Warning level and below:
loggingBuilder.AddGlobalBuffering(LogLevel.Warning);

// 2. If you have an ASP.NET Core app, you can buffer logs
// for each HTTP request/response pair into a separate buffer
// and only for the lifetime of the respective HttpContext.
// If there is no active HttpContext, buffering will be done into the global buffer.
// again, only for the Warning level and below:
loggingBuilder.AddHttpRequestBuffering(LogLevel.Warning);

// 3. Or you can provide configuration via appsetting.json or other configuration providers like this:
var configBuilder = new ConfigurationBuilder();
configBuilder.AddJsonFile("appsettings.json");
var configuration = configBuilder.Build();
var serviceCollection = new ServiceCollection();
serviceCollection.AddLogging(builder =>
{
    builder.AddGlobalBuffering(configuration);
});

// with JSON configuration section like this:
//"Buffering": {
//  "Rules": [
//    {
//      "CategoryName": "Program.MyLogger",
//      "LogLevel": "Information",
//      "EventId": 1,
//      "EventName" : "number one",
//      "Attributes": [
//        {
//          "key": "region",
//          "value": "westus2"
//        },
//        {
//          "key": "priority",
//          "value": 1
//        }
//      ]
//    },
//    {
//      "LogLevel": "Information"
//    }
//  ]
//}

// And trigger flush:
public class MyClass()
{
    public MyClass(LogBuffer logBuffer) {...} // injected via constructor

    private void BusinessCriticalMethod()
    {
      try {}
      catch // something bad happened
      {
         logBuffer.Flush(); // emit all buffered logs
      }
    }
}

// Http request buffering can be used in a middleware:
internal class Middleware : IMiddleware
{
    private readonly ILogger<Middleware> _logger;
    private readonly HttpRequestLogBuffer _buffer;

    public Middleware(ILogger<Middleware> logger, HttpRequestLogBuffer buffer)
    {
        _logger = logger;
        _buffer = buffer;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        _logger.LogInformation($"Middleware is processing this request on {context.Request.Path} path");

        var result = _businessLogic.TryDoSomethingAndLog(logger);

        switch (result)
        {
            case Ok:
                _logger.LogInformation("Request was processed successfully.");
                return;
            case Bad:
                _logger.LogInformation("Request was bad, returning 500.");
                context.Response.StatusCode = 500;
                return;
            case CompleteFailure:
                _logger.LogInformation("Unable to process request, this might lead to an outage.");
                _buffer.FlushCurrentRequestLogs();
                return;
        }

        await next(context).ConfigureAwait(false);
    }
}

Last update: 14.02.2025

Previous Art:

Background and motivation

As a follow-up of this comment and this issue, I am proposing a more generalized solution below.

Note: a new optional State with log record timestamps which mentioned in the Risks section is not part of the proposal. It will be added later as soon as we agree on everything else included in this API proposal.

Background info provided by @geeknoid:

We propose augmenting the .NET logging feature set to expose mechanisms designed specifically to enable application developers to intelligently control the amount of log data pushed by their application to telemetry backends. By eliding redundant log state early in the log pipeline, application developers can dramatically reduce their telemetry COGs.

Log Record Matching

The mechanisms described below depend on the idea of log record matching. As the application produces log records, log record matching is used to determine the specific treatment to apply to each record.
Through configuration, the application specifies an ordered list of matcher functions which are invoked in sequence to determine whether the log record is a match or not. Applications can have custom matchers with whatever sophistication they want, and the platform includes a simple matcher to address the 99% use case. The simple matcher uses configuration state of the form:

class LogRecordPattern
{
    string? Category { get; set; }
    EventId? EventId { get; set; }
    LogLevel? LogLevel { get; set; }
    KeyValuePair<string, string>[]? Tags { get; set; }   
}

These properties are all optional, they are evaluated in order and have AND semantics, so all defined properties must match. When a match occurs, the corresponding configuration state describes what to do with the log record.
Whereas category, event id, and log level are used to match against specific types of log records produced by the application, the tags are used to match against runtime constraints. For example, using tags you can match log records produced from a particular region or cluster, or records that mention a specific artifact or path, etc. For the initial go at this, we propose to do strict equality matching. In other words, for a record to match, it would need to contain all the listed tags with the specific values. Thus, no support for wildcards, regexes, Boolean combinations, etc. This is not intended to be a SQL query, it’s meant to be relatively fast and simple.

Global Controls

These controls apply at the level of the whole application, independent of the context in which logging takes place.

Filtering

Matching records can be filtered. Filtering is implemented via a simple callback model that gives a yes/no response to discard or keep a record. Customers can use a built-in statical sampler to implement this filter or can roll their own.
The statistical sampler we provide works at the application level. It will disable or enable logging of matching records globally for the life of the application based on a random number chosen at startup. This allows a population of application instances to emit the matching records, while the rest of the application instances won’t emit those records.

Buffering

Matching records can be buffered in a named circular buffer. The size and number of circular buffers is determined through configuration.
Buffered records are normally not emitted by the application and just get discarded. To emit buffered records, the application makes an explicit API call, supplying the name of the buffer to flush. Through configuration, the application can control whether to temporarily suspend buffering after a flush has occurred. This makes it possible for an application to obtain log records N seconds before and N seconds after an incident.

Request-Oriented Controls

These controls are aware of the notion of ASP.NET requests and provide the ability to moderate log output within the scope of individual requests.

Filtering

Matching records are filtered or not within the scope of a single request. Like in the global case above, this filtering can use custom application logic or a simple statistical sampler to control which request’s logging records will be filtered out.
In addition to statistical sampling, or perhaps in combination with it, we can factor in hints delivered through distributed tracing to determine whether to filter out matching records.

Buffering

This is like the global buffering described above, except that the buffers being used are not circular and they are private to an individual request’s lifetime. The buffer expands to capture all matching log records for the duration of a request. The buffer’s contents are discarded at the end of a request’s lifetime, unless the application has signaled explicitly that it should be flushed instead.

How It Works

Here’s the flow of things:
• Configuration contains an ordered list of log record match conditions.
• When a record arrives, it is matched against the conditions until a match is found or the end of the list is reached.
• If a match is not found, then the log record is dispatched normally.
• If a match is found, then the configuration state associated with the match is used to determine how to handle the record. One of four things can happen:
o Global Filtering. The configuration state holds a list of filters to invoke, including statistical samplers. If any of the filters returns false, then the log record is discarded and processing ends.
o Global Buffering. The configuration state holds the name of the global buffer the record should be directed to. The log record is serialized and inserted into the named circular buffer and processing ends.
o Request-Level Filtering. The configuration state holds a list of request-oriented filters to invoke, including statistical samplers. If any of the filters returns false, then the log record is discarded and processing ends.
o Request-Level Buffering. The log record is serialized and inserted in the current request’s buffer.

API Proposal

/// <summary>
/// A pattern to match log records against.
/// </summary>
public class LogRecordPattern
{
    /// <summary>
    /// Gets or sets log record category.
    /// </summary>
    public string? Category { get; set; }

    /// <summary>
    /// Gets or sets log record event ID.
    /// </summary>
    public EventId? EventId { get; set; }

    /// <summary>
    /// Gets or sets log record log level.
    /// </summary>
    public LogLevel? LogLevel { get; set; }

    /// <summary>
    /// Gets or sets log records state tags.
    /// </summary>
    public KeyValuePair<string, string>[]? Tags { get; set; }
}

/// <summary>
/// Enumerates actions one of which can be executed on a matching log record.
/// </summary>
public enum ControlAction
{
    /// <summary>
    /// Filter log records globally.
    /// </summary>
    GlobalFilter,

    /// <summary>
    /// Buffer log records globally.
    /// </summary>
    GlobalBuffer,

    /// <summary>
    /// Filter log records withing an HTTP request flow.
    /// </summary>
    RequestFilter,

    /// <summary>
    /// Buffer log records for the duration of an HTTP requst flow.
    /// </summary>
    RequestBuffer
}

/// <summary>
/// Lets you register logging samplers in a dependency injection container.
/// </summary>
public static class LoggingSamplingExtensions
{
    /// <summary>
    /// Enable logging sampling.
    /// </summary>
    /// <param name="builder">An instance of <see cref="ILoggingBuilder"/> to enable sampling in.</param>
    /// <param name="configure">A delegate to fine-tune the sampling.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    public static ILoggingBuilder EnableSampling(this ILoggingBuilder builder, Action<ILoggingSamplingBuilder> configure)
    {
...
    }

    /// <summary>
    /// Set the built-in simple sampler.
    /// </summary>
    /// <param name="builder">An instance of <see cref="ILoggingSamplingBuilder"/> to set the simple sampler in.</param>
    /// <param name="configure">A delegate to fine-tune the sampling.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    public static ILoggingSamplingBuilder SetSimpleSampler(this ILoggingSamplingBuilder builder, Action<LogSamplingOptions> configure)
    {
...
    }

    /// <summary>
    /// Set a logging sampler.
    /// </summary>
    /// <typeparam name="T">A sampler type</typeparam>
    /// <param name="builder">An instance of <see cref="ILoggingSamplingBuilder"/> to set the logging sampler in.</param>
    /// <param name="configure">A delegate to fine-tune the sampling.</param>
    /// <returns>The value of <paramref name="builder"/>.</returns>
    public static ILoggingSamplingBuilder SetSampler<T>(this ILoggingSamplingBuilder builder, Action<LogSamplingOptions> configure)
        where T : class, ILoggingSampler
    {
...
    }
}

/// <summary>
/// Options to configure log sampling.
/// </summary>
public class LogSamplingOptions
{
    /// <summary>
    /// Gets or sets a list of log pattern matchers.
    /// </summary>
    public List<Matcher> Matchers { get; set; }

    /// <summary>
    /// Gets or sets a list of log buffers.
    /// </summary>
    public ISet<LogBuffer> Buffers { get; set; }
}

/// <summary>
/// Represents a component that samples log records.
/// </summary>
public interface ILoggingSampler
{
    /// <summary>
    /// Sample a log record if it matches the <paramref name="logRecordPattern"/>.
    /// </summary>
    /// <param name="logRecordPattern">A log record pattern to match against.</param>
    /// <returns>True, if the log record was sampled. False otherwise.</returns>
    public bool Sample(LogRecordPattern logRecordPattern);
}

/// <summary>
/// An interface for configuring logging sampling.
/// </summary>
public interface ILoggingSamplingBuilder
{
    /// <summary>
    /// Gets the <see cref="IServiceCollection"/> where logging sampling services are configured.
    /// </summary>
    public IServiceCollection Services { get; }
}

/// <summary>
/// Represents a circular log buffer configuration.
/// </summary>
public class LogBuffer
{
    /// <summary>
    /// Gets or sets log buffer name.
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    /// Gets or sets duration to suspend buffering after the flush operation occurred.
    /// </summary>
    public TimeSpan? SuspendAfterFlushDuration { get; set; }

    /// <summary>
    /// Gets or sets a circular buffer duration.
    /// </summary>
    public TimeSpan? BufferingDuration { get; set; }

    /// <summary>
    /// Gets or sets buffer size.
    /// </summary>
    public long? BufferSize { get; set; }
}

// <summary>
/// A log pattern matcher.
/// </summary>
public class Matcher
{
    /// <summary>
    /// Gets a filtering delegate.
    /// </summary>
    public Func<LogRecordPattern, bool>? Filter { get; }

    /// <summary>
    /// Gets a buffering delegate.
    /// </summary>
    public Action<BufferingTool, LogRecordPattern>? Buffer { get; }

    /// <summary>
    /// Gets a control action to perform in case there is a match.
    /// </summary>
    public ControlAction ControlAction { get; }

    /// <summary>
    /// Initializes a new instance of the <see cref="Matcher"/> class.
    /// </summary>
    /// <param name="pattern">A log record pattern to match.</param>
    /// <param name="filter">A global filtering delegate.</param>
    public Matcher(LogRecordPattern pattern, Func<LogRecordPattern, bool> filter)
    {
        ControlAction = ControlAction.GlobalFilter;
        ...
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="Matcher"/> class.
    /// </summary>
    /// <param name="pattern">A log record pattern to match.</param>
    /// <param name="buffer">A global buffering delegate.</param>
    public Matcher(LogRecordPattern pattern, Action<BufferingTool, LogRecordPattern> buffer)
    {
        ControlAction = ControlAction.GlobalBuffer;
        ...
    }

    /// <summary>
    /// Matches the log record pattern against the supplied <paramref name="pattern"/>.
    /// </summary>
    /// <param name="pattern">A log record pattern to match against.</param>
    /// <returns>True if there is a match. False otherwise.</returns>
    public bool Match(LogRecordPattern pattern)
    {
...
    }
}

API Usage

// implementation example in LoggerConfig.cs:
internal sealed class LoggerConfig
{
    public LoggerConfig(..., ILoggingSampler[] samplers)
    {
        Samplers = samplers;
        ...
    }

    public ILoggingSampler[] Samplers { get; }
}

// implementation example in ExtendedLogger.cs:
    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
...
        var pattern = new LogRecordPattern
        {
            Category = // get category,
            Tags = state,
            EventId = eventId,
            LogLevel = logLevel,
        };

        foreach (var sampler in _factory.Config.Samplers)
        {
            if (sampler.Sample(pattern))
            {
                return; // the record was sampled, hence we don't log it.
            }
        }
    }

// an example of the built-in simple sampler implementation:
internal class SimpleSampler : ILoggingSampler
{
    private readonly List<Matcher> _matchers;
    private readonly BufferingTool _bufferingTool;

    public SimpleSampler(IOptions<LogSamplingOptions> options, BufferingTool bufferingTool)
    {
        _matchers = options.Value.Matchers;
        _bufferingTool = bufferingTool;
    }

    public bool Sample(LogRecordPattern logRecordPattern)
    {
        foreach (var matcher in _matchers)
        {
            if (matcher.Match(logRecordPattern))
            {
                switch (matcher.ControlAction)
                {
                    case ControlAction.GlobalFilter:
                        if (!matcher.Filter(logRecordPattern))
                        {
                            return true;
                        }

                        break;
                    case ControlAction.GlobalBuffer:
                        matcher.Buffer(_bufferingTool, logRecordPattern);
                        return true;
                    case ControlAction.RequestFilter:
                        break;
                    case ControlAction.RequestBuffer:
                        break;
                }
            }
        }

        return false;
    }
}

// usage example:
LoggerFactory.Create(builder =>
{
    _ = builder.EnableSampling(samplingBuilder => samplingBuilder

            // add the built-in simple sampler:
            .SetSimpleSampler(o =>
            {
                o.Matchers = new List<Matcher>
                {
                    new Matcher(
                        new LogRecordPattern
                        {
                            LogLevel = LogLevel.Information,
                            Category = "Microsoft.Extensions.Hosting",
                        },

                        // drop 99% of logs of Hosting category:
                        (pattern) => Random.Shared.NextDouble() < 0.01),
                    new Matcher(
                        new LogRecordPattern
                        {
                            LogLevel = LogLevel.Error,
                        },

                       // buffer all error logs:
                        (tool, pattern) => tool.Buffer("MyBuffer")),
                };
                o.Buffers = new HashSet<LogBuffer>
                {
                    new LogBuffer
                    {
                        Name = "MyBuffer",
                        SuspendAfterFlushDuration = TimeSpan.FromSeconds(10),
                        BufferingDuration = TimeSpan.FromSeconds(10),
                        BufferSize = 1_000_000,
                    },
                };
            }));
});

Alternative Designs

Previous proposal is here

Risks

Provided by @geeknoid:

Buffering Challenges

The existing logging infrastructure was not designed to support buffering. In particular, if log records are buffered early in the infrastructure, the records will not have been timestamped, and would get timestamped only when they were emitted from the process. This is not acceptable.
To support the model, we will augment the logging infrastructure to pass new optional state down with every log record flowing through the system, which will include the timestamp at which the record was captured. Log processing engines, like OpenTelemetry, will need to be retrofitted to recognize this state and use the supplied timestamp instead of capturing a timestamp themselves.
If a log provider hasn’t been upgraded to recognize the new optional state, the log records flowing through that provider will be timestamped with an incorrect value. Developers will be made aware to watch for this issue when first enabling buffering and to upgrade their log provider if needed.
A second challenge with buffering is that the TState value supplied by the application to the logging infrastructure is expected to be fully consumed before the Log method returns to the application. If we simply held on to the TState instances during buffering, we could easily end up with invalid state by the time the buffer is flushed. To avoid this, we need to serialize the TState to a neutral form before returning to the application.

Performance Considerations

The performance profile of an application will be impacted by filtering and buffering behavior. In particular, buffering can lead to potentially substantial spikes in log output when problems occur.
This trade-off is unavoidable, application developers need to be made aware so that they can make informed choices. We need to make it easy to turn off all these mechanisms to enable application developers to experience/measure the worst-case scenario.
It’s interesting to consider the effects of global vs request-oriented controls:
• With global controls, an individual application process either outputs matching log records for its life time or it doesn’t. So some processes will have a higher computational burden then others. We could consider reevaluating whether a process should output those log records on a regular basis (say every hour), but it’s not clear whether this flexibility is needed/desirable.
• With request-level controls, the performance of individual requests processed by every application process varies. Some requests have more overhead than others.

Metadata

Metadata

Labels

api-approvedAPI was approved in API review, it can be implementedarea-telemetry

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions