Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add logging buffering #5635

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
988c709
Buffering
evgenyfedorov2 Nov 13, 2024
2f1a335
Major update
evgenyfedorov2 Dec 7, 2024
2d2412e
Remove Json exception converter
evgenyfedorov2 Dec 12, 2024
f7eaab1
Fix namespaces
evgenyfedorov2 Dec 16, 2024
1f464df
Merge branch 'main' into evgenyfedorov2/log_buffering
evgenyfedorov2 Dec 16, 2024
a371d9c
Fix build
evgenyfedorov2 Dec 16, 2024
d7661a6
Slight design changes with interfaces as per PR comments
evgenyfedorov2 Jan 8, 2025
9d13ab0
Drop json serialization
evgenyfedorov2 Jan 9, 2025
fe00658
Add log record size estimation and limit buffer size in bytes
evgenyfedorov2 Jan 13, 2025
5fc421c
Add filtering by attributes
evgenyfedorov2 Jan 15, 2025
70cfc7c
Use attributes directly instead of Func delegate
evgenyfedorov2 Jan 21, 2025
e96277f
Add http buffer holder
evgenyfedorov2 Jan 21, 2025
a79fcbf
Make ILoggingBuffer and DeserializedLogRecord types internal
evgenyfedorov2 Jan 22, 2025
8a91c15
Move shared files to Shared project and add more tests
evgenyfedorov2 Jan 22, 2025
4f524eb
Add custom equality comparer
evgenyfedorov2 Jan 22, 2025
b2b6e56
Address API Review feedback
evgenyfedorov2 Feb 14, 2025
f3a6b85
Merge branch 'main' into evgenyfedorov2/log_buffering
evgenyfedorov2 Feb 14, 2025
1370225
merge
evgenyfedorov2 Feb 28, 2025
393ce26
API review feedback
evgenyfedorov2 Feb 28, 2025
20b5a4c
Remove extra lines
evgenyfedorov2 Feb 28, 2025
f5ce71e
Make tests culture agnostic
Mar 11, 2025
ce2f7fb
Global log buffering - options validation, rule selector optimization
Mar 11, 2025
6ffbacd
Rename to PerIncomingRequest
evgenyfedorov2 Mar 13, 2025
1370ccb
Minor updates
Mar 14, 2025
56257ca
More renames
Mar 14, 2025
73a8678
Rename shared folder LoggingBuffering to LogBuffering
Mar 14, 2025
8a42385
Remove per request options refresh because buffer are scoped anyway
Mar 14, 2025
7ff16fc
Remove unnecessary casting
Mar 14, 2025
35223f3
Add DebuggerDisplay to SerializedLogRecord.cs
Mar 15, 2025
bf0b59d
Added pooling of log record attributes lists
Mar 17, 2025
715ce25
Moved validation for max one asterisk in log category to options vali…
Mar 17, 2025
ccdfaeb
Add size of SerializedLogRecord struct to ballpark size estimation
Mar 17, 2025
4e1566b
Added a remark
Mar 17, 2025
5171413
Added remarks on buffer filter rules
Mar 17, 2025
de83448
Enable log buffering for .NET 8
Mar 17, 2025
b137e88
Revert "Enable log buffering for .NET 8"
Mar 18, 2025
821ff0b
Fix warnings
Mar 18, 2025
406df25
Revert unnecessary changes
evgenyfedorov2 Mar 18, 2025
4dc02f4
update
evgenyfedorov2 Mar 18, 2025
053d6c1
Merge branch 'main' into evgenyfedorov2/log_buffering
evgenyfedorov2 Mar 18, 2025
6a26ce8
PR feedback
Mar 19, 2025
c93a183
.
Mar 19, 2025
542bdd2
Improve string size calculation
Mar 19, 2025
6c85897
Refactor TryEnqueue
Mar 20, 2025
4248980
Add a double-buffer pattern to avoid race conditions
Mar 20, 2025
c824379
Flush in batches and pool lists for emitted records
Mar 20, 2025
1bc60c0
Fix racing conditions for buffers
Mar 20, 2025
2e2a6a4
Merge branch 'main' into evgenyfedorov2/log_buffering
evgenyfedorov2 Mar 26, 2025
5e49d69
Use two buffers
Mar 21, 2025
f6a2dec
Add more list pools
Mar 24, 2025
6e92aec
More tests
Mar 27, 2025
1fef67b
Update
Apr 1, 2025
51d67d4
Merge branch 'main' into evgenyfedorov2/log_buffering
evgenyfedorov2 Apr 2, 2025
621578a
PR Comments
Apr 9, 2025
ec2ef64
Merge branch 'main' into evgenyfedorov2/log_buffering
evgenyfedorov2 Apr 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eng/MSBuild/Shared.props
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,8 @@
<ItemGroup Condition="'$(InjectStringSplitExtensions)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\StringSplit\*.cs" LinkBase="Shared\StringSplit" />
</ItemGroup>

<ItemGroup Condition="'$(InjectSharedLogBuffering)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\LogBuffering\*.cs" LinkBase="Shared\LogBuffering" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#if NET9_0_OR_GREATER
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Diagnostics.Buffering;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.ObjectPool;
using Microsoft.Extensions.Options;
using Microsoft.Shared.Diagnostics;
using Microsoft.Shared.Pools;

namespace Microsoft.AspNetCore.Diagnostics.Buffering;

internal sealed class IncomingRequestLogBuffer : IDisposable
{
private const int MaxBatchSize = 256;
private static readonly ObjectPool<List<DeserializedLogRecord>> _recordsToEmitListPool =
PoolFactory.CreateListPoolWithCapacity<DeserializedLogRecord>(MaxBatchSize);

private readonly IBufferedLogger _bufferedLogger;
private readonly LogBufferingFilterRuleSelector _ruleSelector;
private readonly IOptionsMonitor<PerRequestLogBufferingOptions> _options;
private readonly TimeProvider _timeProvider = TimeProvider.System;
private readonly LogBufferingFilterRule[] _filterRules;
private readonly Lock _bufferSwapLock = new();
private volatile bool _disposed;
private ConcurrentQueue<SerializedLogRecord> _activeBuffer = new();
private ConcurrentQueue<SerializedLogRecord> _standbyBuffer = new();
private int _activeBufferSize;
private DateTimeOffset _lastFlushTimestamp;

public IncomingRequestLogBuffer(
IBufferedLogger bufferedLogger,
string category,
LogBufferingFilterRuleSelector ruleSelector,
IOptionsMonitor<PerRequestLogBufferingOptions> options)
{
_bufferedLogger = bufferedLogger;
_ruleSelector = ruleSelector;
_options = options;
_filterRules = LogBufferingFilterRuleSelector.SelectByCategory(_options.CurrentValue.Rules.ToArray(), category);
}

public bool TryEnqueue<TState>(LogEntry<TState> logEntry)
{
if (_timeProvider.GetUtcNow() < _lastFlushTimestamp + _options.CurrentValue.AutoFlushDuration)
{
return false;
}

IReadOnlyList<KeyValuePair<string, object?>>? attributes = logEntry.State as IReadOnlyList<KeyValuePair<string, object?>>;
if (attributes is null)
{
// we expect state to be either ModernTagJoiner or LegacyTagJoiner
// which both implement IReadOnlyList<KeyValuePair<string, object?>>
// and if not, we throw an exception
Throw.InvalidOperationException(
$"Unsupported type of log state detected: {typeof(TState)}, expected IReadOnlyList<KeyValuePair<string, object?>>");
}

if (_ruleSelector.Select(_filterRules, logEntry.LogLevel, logEntry.EventId, attributes) is null)
{
// buffering is not enabled for this log entry,
// return false to indicate that the log entry should be logged normally.
return false;
}

SerializedLogRecord serializedLogRecord = SerializedLogRecordFactory.Create(
logEntry.LogLevel,
logEntry.EventId,
_timeProvider.GetUtcNow(),
attributes,
logEntry.Exception,
logEntry.Formatter(logEntry.State, logEntry.Exception));

if (serializedLogRecord.SizeInBytes > _options.CurrentValue.MaxLogRecordSizeInBytes)
{
SerializedLogRecordFactory.Return(serializedLogRecord);
return false;
}

lock (_bufferSwapLock)
{
_activeBuffer.Enqueue(serializedLogRecord);
_ = Interlocked.Add(ref _activeBufferSize, serializedLogRecord.SizeInBytes);

}

TrimExcessRecords();

return true;
}

public void Flush()
{
_lastFlushTimestamp = _timeProvider.GetUtcNow();

ConcurrentQueue<SerializedLogRecord> tempBuffer;
int numItemsToEmit;
lock (_bufferSwapLock)
{
tempBuffer = _activeBuffer;
_activeBuffer = _standbyBuffer;
_standbyBuffer = tempBuffer;

numItemsToEmit = tempBuffer.Count;

_ = Interlocked.Exchange(ref _activeBufferSize, 0);
}

for (int offset = 0; offset < numItemsToEmit && !tempBuffer.IsEmpty; offset += MaxBatchSize)
{
int currentBatchSize = Math.Min(MaxBatchSize, numItemsToEmit - offset);
List<DeserializedLogRecord> recordsToEmit = _recordsToEmitListPool.Get();
try
{
for (int i = 0; i < currentBatchSize && tempBuffer.TryDequeue(out SerializedLogRecord bufferedRecord); i++)
{
recordsToEmit.Add(new DeserializedLogRecord(
bufferedRecord.Timestamp,
bufferedRecord.LogLevel,
bufferedRecord.EventId,
bufferedRecord.Exception,
bufferedRecord.FormattedMessage,
bufferedRecord.Attributes));
}

_bufferedLogger.LogRecords(recordsToEmit);
}
finally
{
_recordsToEmitListPool.Return(recordsToEmit);
}
}
}

public void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;

_ruleSelector.InvalidateCache();
}

private void TrimExcessRecords()
{
while (_activeBufferSize > _options.CurrentValue.MaxPerRequestBufferSizeInBytes &&
_activeBuffer.TryDequeue(out SerializedLogRecord item))
{
_ = Interlocked.Add(ref _activeBufferSize, -item.SizeInBytes);
SerializedLogRecordFactory.Return(item);
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#if NET9_0_OR_GREATER
using System;
using System.Collections.Concurrent;

namespace Microsoft.AspNetCore.Diagnostics.Buffering;

internal sealed class IncomingRequestLogBufferHolder : IDisposable
{
private readonly ConcurrentDictionary<string, IncomingRequestLogBuffer> _buffers = new();
private bool _disposed;

public IncomingRequestLogBuffer GetOrAdd(string category, Func<string, IncomingRequestLogBuffer> valueFactory) =>
_buffers.GetOrAdd(category, valueFactory);

public void Flush()
{
foreach (IncomingRequestLogBuffer buffer in _buffers.Values)
{
buffer.Flush();
}
}

public void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;

foreach (IncomingRequestLogBuffer buffer in _buffers.Values)
{
buffer.Dispose();
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#if NET9_0_OR_GREATER

using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Diagnostics.Buffering;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.Buffering;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.Logging;

/// <summary>
/// Lets you register per incoming request log buffering in a dependency injection container.
/// </summary>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public static class PerIncomingRequestLoggingBuilderExtensions
{
/// <summary>
/// Adds per incoming 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"/> or <paramref name="configuration"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Matched logs will be buffered in a buffer specific to each incoming request
/// and can optionally be flushed and emitted during the request lifetime.
/// </remarks>
public static ILoggingBuilder AddPerIncomingRequestBuffer(this ILoggingBuilder builder, IConfiguration configuration)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(configuration);

_ = builder.Services
.AddSingleton<IConfigureOptions<PerRequestLogBufferingOptions>>(new PerRequestLogBufferingConfigureOptions(configuration))
.AddOptionsWithValidateOnStart<PerRequestLogBufferingOptions, PerRequestLogBufferingOptionsValidator>()
.Services.AddOptionsWithValidateOnStart<PerRequestLogBufferingOptions, PerRequestLogBufferingOptionsCustomValidator>();

return builder
.AddPerRequestBufferManager()
.AddGlobalBuffer(configuration);
}

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

_ = builder.Services
.AddOptionsWithValidateOnStart<PerRequestLogBufferingOptions, PerRequestLogBufferingOptionsValidator>()
.Services.AddOptionsWithValidateOnStart<PerRequestLogBufferingOptions, PerRequestLogBufferingOptionsCustomValidator>()
.Configure(configure);

PerRequestLogBufferingOptions options = new PerRequestLogBufferingOptions();
configure(options);

return builder
.AddPerRequestBufferManager()
.AddGlobalBuffer(opts => opts.Rules = options.Rules);
}

/// <summary>
/// Adds per incoming request log buffering to the logging infrastructure.
/// </summary>
/// <param name="builder">The <see cref="ILoggingBuilder" />.</param>
/// <param name="logLevel">The level (and below) of logs to buffer.</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 incoming request
/// and can optionally be flushed and emitted during the request lifetime.
/// </remarks>
public static ILoggingBuilder AddPerIncomingRequestBuffer(this ILoggingBuilder builder, LogLevel? logLevel = null)
{
_ = Throw.IfNull(builder);

_ = builder.Services
.AddOptionsWithValidateOnStart<PerRequestLogBufferingOptions, PerRequestLogBufferingOptionsValidator>()
.Services.AddOptionsWithValidateOnStart<PerRequestLogBufferingOptions, PerRequestLogBufferingOptionsCustomValidator>()
.Configure(options =>
{
options.Rules.Add(new LogBufferingFilterRule(logLevel: logLevel));
});

return builder
.AddPerRequestBufferManager()
.AddGlobalBuffer(logLevel);
}

private static ILoggingBuilder AddPerRequestBufferManager(this ILoggingBuilder builder)
{
builder.Services.TryAddScoped<IncomingRequestLogBufferHolder>();
builder.Services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
builder.Services.TryAddSingleton(sp =>
{
var globalBufferManager = sp.GetRequiredService<GlobalLogBufferManager>();
return ActivatorUtilities.CreateInstance<PerRequestLogBufferManager>(sp, globalBufferManager);
});
builder.Services.TryAddSingleton<LogBuffer>(sp => sp.GetRequiredService<PerRequestLogBufferManager>());
builder.Services.TryAddSingleton<PerRequestLogBuffer>(sp => sp.GetRequiredService<PerRequestLogBufferManager>());

return builder;
}
}

#endif
Loading
Loading