-
Notifications
You must be signed in to change notification settings - Fork 763
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
base: main
Are you sure you want to change the base?
Add logging buffering #5635
Conversation
src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs
Outdated
Show resolved
Hide resolved
...Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/HttpRequestBufferProvider.cs
Outdated
Show resolved
Hide resolved
/// <summary> | ||
/// Gets or sets the size of the buffer for a request. | ||
/// </summary> | ||
public int PerRequestCapacity { get; set; } = 1_000; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this measured in log messages? I think we should also consider if we need guards on the size of an individual message or size of all messages combined. For example if someone logged 500 messages each of size 10MB I expect many services would fail with an OOM. I don't expect anyone would intentionally log such a large message but it might happen because of an unintended bug or a malicious actor has discovered a way to get large content into a log message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it is in log messages. I don't know yet how to make performant memory size limit for that, but I will keep this thread open to get back to it later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be a total size of the underlying buffer? "Don't buffer more than N bytes"?
src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBufferOptions.cs
Outdated
Show resolved
Hide resolved
...Core.Diagnostics.Middleware.Tests/Buffering/HttpRequestBufferLoggerBuilderExtensionsTests.cs
Outdated
Show resolved
Hide resolved
@@ -714,6 +718,48 @@ await RunAsync( | |||
}); | |||
} | |||
|
|||
#if NET9_0_OR_GREATER | |||
[Fact] | |||
public async Task HttpRequestBuffering() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't have to be this test, but it would be nice to have some test or sample code that uses the feature the way we expect an end-user to use it. For example I would not expect end users to new up the Options type, create their own instances of HttpRequestBuffer or manually add objects to the DI container.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated this test, users (as of today) are supposed to inject IHttpRequestBufferManager
into their middleware and call Flush()
and/or FlushCurrentRequestLogs()
on it
...s/Microsoft.Extensions.Telemetry.Tests/Buffering/GlobalBufferLoggerBuilderExtensionsTests.cs
Outdated
Show resolved
Hide resolved
test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerTests.cs
Outdated
Show resolved
Hide resolved
e40b7b6
to
9d61d12
Compare
9d61d12
to
2f1a335
Compare
/// Interface for a HTTP request buffer manager. | ||
/// </summary> | ||
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] | ||
public interface IHttpRequestBufferManager : IBufferManager |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this type be internal?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Users will use buffering either via this interface (for HTTP or non-HTTP request logs or for both) or via IBufferManager
(for non-HTTP request logs only) injected via constructor, that's why it should be public
.
/// Defines a rule used to filter log messages for purposes of futher buffering. | ||
/// </summary> | ||
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] | ||
public class BufferFilterRule : ILoggerFilterRule |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we need to also support filtering per name/value pair. So that you can control buffering based on things like cluster name or region.
...soft.AspNetCore.Diagnostics.Middleware/Buffering/HttpRequestBufferLoggerBuilderExtensions.cs
Outdated
Show resolved
Hide resolved
src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBufferOptions.cs
Outdated
Show resolved
Hide resolved
public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(30); | ||
|
||
/// <summary> | ||
/// Gets or sets the size of the buffer. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this in log records? If so, I think this should be expressed in bytes, since the total size of individual log records may vary wildly.
if (!httpContext.Items.TryGetValue(category, out var buffer)) | ||
{ | ||
var httpRequestBuffer = new HttpRequestBuffer(bufferSink, _requestOptions, _globalOptions); | ||
httpContext.Items[category] = httpRequestBuffer; | ||
return httpRequestBuffer; | ||
} | ||
|
||
if (buffer is not ILoggingBuffer loggingBuffer) | ||
{ | ||
throw new InvalidOperationException($"Unable to parse value of {buffer} of the {category}"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if the key in HttpContext.Items should be something more complex than just string category
, in case a library is already using the same string as a log category name and as a key in HttpContext.Items, and an application that uses this library is being changed to use log buffering.
Another risk here is that HttpContext is not thread-safe. If the request causes asynchronous work on multiple threads, and those threads log events in parallel (with the same log category or different categories), then the HttpContext.Items dictionary could be corrupted. Not sure how to fix this.
Address PR comments Add .NET 8 support Add ExceptionJsonConverter
883334d
to
022f00c
Compare
022f00c
to
a371d9c
Compare
🎉 Good job! The coverage increased 🎉
Full code coverage report: https://dev.azure.com/dnceng-public/public/_build/results?buildId=896358&view=codecoverage-tab |
// NOTE: This implementation uses thread local storage. As a result, it will fail if formatter code, enricher code, or | ||
// NOTE: This implementation uses thread local storage. As a wasBuffered, it will fail if formatter code, enricher code, or |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That change looks unintended.
{ | ||
case ModernTagJoiner modernTagJoiner: | ||
_buffer.Enqueue(new SerializedLogRecord(logLevel, eventId, _timeProvider.GetUtcNow(), modernTagJoiner, exception, | ||
((Func<ModernTagJoiner, Exception?, string>)(object)formatter)(modernTagJoiner, exception))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this casting more performant than just formatter(attributes, exception)
?
internal interface ILoggingBuffer | ||
{ | ||
/// <summary> | ||
/// Enqueues a log record in the underlying buffer.. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Double dot. But I'm not sure whether internal interfaces should even have XML documentation comments.
public void Flush() | ||
{ | ||
var result = _buffer.ToArray(); | ||
|
||
#if NETFRAMEWORK | ||
lock (_netfxBufferLocker) | ||
{ | ||
while (_buffer.TryDequeue(out _)) | ||
{ | ||
// Clear the buffer | ||
} | ||
} | ||
#else | ||
_buffer.Clear(); | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This loses log entries if another thread enqueues more of them between _buffer.ToArray()
and _buffer.Clear()
. That is more likely to happen on .NET Framework, where clearing the queue takes more time.
{ | ||
while (!cancellationToken.IsCancellationRequested) | ||
{ | ||
await _timeProvider.Delay(_options.CurrentValue.Duration, cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IIRC, IOptionsMonitor<TOptions>.CurrentValue throws if the option is bound to configuration and the binding fails (parsing fails or a validator rejects the value); this could even happen when an operator modifies a configuration file after the application has already started. The BackgroundService would then fail and, depending on HostOptions.BackgroundServiceExceptionBehavior, either log buffer truncation would stop working or the whole application would terminate. Should this instead keep using the previous value of the options in that case?
/// <summary> | ||
/// Represents a rule used for filtering log messages for purposes of log sampling and buffering. | ||
/// </summary> | ||
internal interface ILoggerFilterRule |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ILoggerFilterRule interface doesn't look useful when it is internal
and has only one implementation and has only read-only properties (rather than a method that checks whether the rule is applicable). LoggerFilterRuleSelector could instead use the BufferFilterRule class directly.
logger.Log( | ||
serializedRecord.LogLevel, | ||
serializedRecord.EventId, | ||
serializedRecord.Attributes, | ||
exception, | ||
(_, _) => serializedRecord.FormattedMessage ?? string.Empty); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This allocates a closure and a delegate for each ILogger.Log<TState> call. I can think of two ways to minimize the allocations:
- Instead of
serializedRecord.Attributes
, passserializedRecord
itself as the state to ILogger.Log<TState>. ImplementIReadOnlyList<KeyValuePair<string, object?>>
in SerializedLogRecord by calling the corresponding methods ofpublic IReadOnlyList<KeyValuePair<string, string>> Attributes
. - Alternatively, keep using the same delegate across all iterations of both
foreach
loops; declare theserializedRecord
variable and a delegate variable before the outer loop. That way, it would allocate only one closure and one delegate per BufferSink.LogRecords call, no matter how many log records are inIEnumerable<T> serializedRecords
. This is safe because ILogger.Log<TState> is not allowed to save the formatter delegate for later calling.
} | ||
else | ||
{ | ||
exception = Activator.CreateInstance(deserializedType, message) as Exception; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will pass the wrong argument to ArgumentNullException(string? paramName).
exception = Activator.CreateInstance(deserializedType, message, innerExceptions.First()) as Exception; | ||
} | ||
} | ||
else if (innerException != null) | ||
{ | ||
exception = Activator.CreateInstance(deserializedType, message, innerException) as Exception; | ||
} | ||
else | ||
{ | ||
exception = Activator.CreateInstance(deserializedType, message) as Exception; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the recovery plan if the exception type has no suitable constructor and Activator.CreateInstance throws MethodInvocationException? For example, System.ServiceModel.FaultException<TDetail> does not have any constructors that take just (String) or (String, Exception).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reconstruction of exception instances from JSON would not be necessary if loggers that do not implement IBufferedLogger were given the log entries immediately without buffering.
using Microsoft.Extensions.ObjectPool; | ||
|
||
namespace Microsoft.Extensions.Diagnostics.Buffering; | ||
internal sealed class PooledLogRecord : BufferedLogRecord, IResettable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add DebuggerDisplayAttribute so that the list of log records in a buffer can be viewed more easily.
public override IReadOnlyList<KeyValuePair<string, object?>> Attributes => _attributes; | ||
private IReadOnlyList<KeyValuePair<string, object?>> _attributes; | ||
|
||
public bool TryReset() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What calls PooledLogRecord.TryReset()? BufferSink does PoolFactory.CreateListPool<PooledLogRecord>()
and _logRecordPool.Return(pooledList)
, but PooledListPolicy<T>.Return(List<T>) clears only the list itself and does not call IResettable.Reset() on the elements of the list.
/// <summary> | ||
/// Gets the maximum <see cref="LogLevel"/> of messages where this rule applies to. | ||
/// </summary> | ||
public int? EventId { get; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrong documentation.
public override string? MessageTemplate => _messageTemplate; | ||
private string? _messageTemplate; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PooledLogRecord._messageTemplate is always null. Only PooledLogRecord.TryReset writes to it.
pooledList.Add( | ||
new PooledLogRecord( | ||
serializedRecord.Timestamp, | ||
serializedRecord.LogLevel, | ||
serializedRecord.EventId, | ||
serializedRecord.Exception, | ||
serializedRecord.FormattedMessage, | ||
serializedRecord.Attributes.Select(kvp => new KeyValuePair<string, object?>(kvp.Key, kvp.Value)).ToArray())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These PooledLogRecord instances should presumably be rented from a pool but that has not been implemented; only the List<PooledLogRecord> instances are pooled, not the records themselves.
If the record were pooled, then it might be useful to pool the PooledLogRecord.Attributes arrays too. For that, there are a few options:
- Change the type of PooledLogRecord._attributes to List<KeyValuePair<string, object>>. When the PooledLogRecord instance is returned to the pool, keep the attribute list attached; when the instance is rented again from the pool, reuse the attribute list and resize it if necessary.
- Con: If most log entries have no attributes, but some rare log entries have hundreds of attributes, then over time most PooledLogRecord instances in the pool would get their attribute lists expanded to the largest size, wasting memory.
- When the PooledLogRecord instance is returned to the pool, detach the attribute array and pool it separately in an ArrayPool<KeyValuePair<string, object>>.
- Pro: Separate buckets for different array sizes.
- Con: Because ArrayPool<T>.Rent can return a larger array than requested, PooledLogRecord.Attributes would have to return a size-limiting wrapper rather than the array itself. ArraySegment<T> implements IReadOnlyList<T> but it would have to be boxed anew each time the backing array is replaced. A custom class that implements IReadOnlyList<T> and allows reinitializing with a different array should work but I'm not sure about the performance.
#if NETFRAMEWORK | ||
serializedAttributes.Add(new KeyValuePair<string, string>(new string(attributes[i].Key.ToCharArray()), attributes[i].Value?.ToString() ?? string.Empty)); | ||
#else | ||
serializedAttributes.Add(new KeyValuePair<string, string>(new string(attributes[i].Key), attributes[i].Value?.ToString() ?? string.Empty)); | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does this copy the attribute key strings -- is that related to collectible assembly load contexts?
public override IReadOnlyList<KeyValuePair<string, object?>> Attributes => _attributes; | ||
private IReadOnlyList<KeyValuePair<string, object?>> _attributes; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is BufferedLogRecord.Attributes expected to include the "{OriginalFormat}" entry whose value should be in BufferedLogRecord.MessageTemplate too?
if (!category.AsSpan().StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || | ||
!category.AsSpan().EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) | ||
{ | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A length check seems to be missing. Consider rule.Category = "abb*bbc", which causes prefix = "abb" and suffix = "bbc". Then if category = "abbc", it should not match because it is too short, but this code accepts it because it starts with "abb" and ends with "bbc".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed dotnet/runtime#110890 for a similar bug in Microsoft.Extensions.Logging.LoggerRuleSelector.
if (wildcardIndex != -1 && | ||
categoryName.IndexOf(WildcardChar, wildcardIndex + 1) != -1) | ||
{ | ||
throw new InvalidOperationException("Only one wildcard character is allowed in category name."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this error were thrown from IValidateOptions<GlobalBufferOptions> and IValidateOptions<HttpRequestBufferOptions>, the error might be easier to handle there, especially during configuration reload.
Related to the #5123 proposal, this PR is focused on the logging buffering only. See also the sampling part #5574.
Microsoft Reviewers: Open in CodeFlow