Skip to content

Commit b287cfd

Browse files
Add logging sampling (#5574)
1 parent 81e9913 commit b287cfd

24 files changed

+1237
-23
lines changed

src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj

-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
<InjectSharedPools>false</InjectSharedPools>
1818
<InjectSharedBufferWriterPool>true</InjectSharedBufferWriterPool>
1919
<InjectSharedNumericExtensions>false</InjectSharedNumericExtensions>
20-
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
2120
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
2221
</PropertyGroup>
2322

src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj

-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
<UseMetricsReportsGenerator>true</UseMetricsReportsGenerator>
1515
<InjectGetOrAddOnLegacy>false</InjectGetOrAddOnLegacy>
1616
<InjectTaskWaitAsyncOnLegacy>true</InjectTaskWaitAsyncOnLegacy>
17-
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
18-
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
1917
<InjectTrimAttributesOnLegacy>false</InjectTrimAttributesOnLegacy>
2018
<InjectSharedDebugger>true</InjectSharedDebugger>
2119
<InjectSharedDataValidation>false</InjectSharedDataValidation>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
using Microsoft.Shared.DiagnosticIds;
7+
8+
namespace Microsoft.Extensions.Logging;
9+
10+
/// <summary>
11+
/// Controls the number of samples of log records collected and sent to the backend.
12+
/// </summary>
13+
#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods
14+
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
15+
public abstract class LoggingSampler
16+
#pragma warning restore S1694 // An abstract class should have both abstract and concrete methods
17+
{
18+
/// <summary>
19+
/// Makes a sampling decision for the provided <paramref name="logEntry"/>.
20+
/// </summary>
21+
/// <param name="logEntry">The log entry used to make the sampling decision for.</param>
22+
/// <typeparam name="TState">The type of the log entry state.</typeparam>
23+
/// <returns><see langword="true" /> if the log record should be sampled; otherwise, <see langword="false" />.</returns>
24+
public abstract bool ShouldSample<TState>(in LogEntry<TState> logEntry);
25+
}

src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs

+38-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Diagnostics;
7-
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Logging.Abstractions;
88
using Microsoft.Shared.Pools;
99

1010
namespace Microsoft.Extensions.Logging;
@@ -15,7 +15,7 @@ namespace Microsoft.Extensions.Logging;
1515
// redactor code calls recursively back into the logger. Don't do that.
1616
//
1717
// NOTE: Unlike the original logger in dotnet/runtime, this logger eats exceptions thrown from invoked loggers, enrichers,
18-
// and redactors, rather than forwarding the exceptions to the caller. The fact an exception occured is recorded in
18+
// and redactors, rather than forwarding the exceptions to the caller. The fact an exception occurred is recorded in
1919
// the event log instead. The idea is that failures in the telemetry stack should not lead to failures in the
2020
// application. It's better to keep running with missing telemetry rather than crashing the process completely.
2121

@@ -49,7 +49,7 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
4949
}
5050
}
5151

52-
LegacyPath<TState>(logLevel, eventId, state, exception, formatter);
52+
LegacyPath(logLevel, eventId, state, exception, formatter);
5353
}
5454

5555
public IDisposable? BeginScope<TState>(TState state)
@@ -261,16 +261,33 @@ private void ModernPath(LogLevel logLevel, EventId eventId, LoggerMessageState m
261261
RecordException(exception, joiner.EnrichmentTagCollector, config);
262262
}
263263

264+
bool? samplingDecision = null;
264265
for (int i = 0; i < loggers.Length; i++)
265266
{
266267
ref readonly MessageLogger loggerInfo = ref loggers[i];
267268
if (loggerInfo.IsNotFilteredOut(logLevel))
268269
{
270+
if (samplingDecision is null && config.Sampler is not null)
271+
{
272+
var logEntry = new LogEntry<ModernTagJoiner>(logLevel, loggerInfo.Category, eventId, joiner, exception, static (s, e) =>
273+
{
274+
Func<LoggerMessageState, Exception?, string> fmt = s.Formatter!;
275+
return fmt(s.State!, e);
276+
});
277+
samplingDecision = config.Sampler.ShouldSample(in logEntry);
278+
}
279+
280+
if (samplingDecision is false)
281+
{
282+
// the record was not selected for being sampled in, so we drop it.
283+
break;
284+
}
285+
269286
try
270287
{
271288
loggerInfo.LoggerLog(logLevel, eventId, joiner, exception, static (s, e) =>
272289
{
273-
var fmt = s.Formatter!;
290+
Func<LoggerMessageState, Exception?, string>? fmt = s.Formatter!;
274291
return fmt(s.State!, e);
275292
});
276293
}
@@ -345,11 +362,28 @@ private void LegacyPath<TState>(LogLevel logLevel, EventId eventId, TState state
345362
RecordException(exception, joiner.EnrichmentTagCollector, config);
346363
}
347364

365+
bool? samplingDecision = null;
348366
for (int i = 0; i < loggers.Length; i++)
349367
{
350368
ref readonly MessageLogger loggerInfo = ref loggers[i];
351369
if (loggerInfo.IsNotFilteredOut(logLevel))
352370
{
371+
if (samplingDecision is null && config.Sampler is not null)
372+
{
373+
var logEntry = new LogEntry<LegacyTagJoiner>(logLevel, loggerInfo.Category, eventId, joiner, exception, static (s, e) =>
374+
{
375+
var fmt = (Func<TState, Exception?, string>)s.Formatter!;
376+
return fmt((TState)s.State!, e);
377+
});
378+
samplingDecision = config.Sampler.ShouldSample(in logEntry);
379+
}
380+
381+
if (samplingDecision is false)
382+
{
383+
// the record was not selected for being sampled in, so we drop it.
384+
break;
385+
}
386+
353387
try
354388
{
355389
loggerInfo.Logger.Log(logLevel, eventId, joiner, exception, static (s, e) =>

src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ internal sealed class ExtendedLoggerFactory : ILoggerFactory
2323
private readonly IDisposable? _enrichmentOptionsChangeTokenRegistration;
2424
private readonly IDisposable? _redactionOptionsChangeTokenRegistration;
2525
private readonly Action<IEnrichmentTagCollector>[] _enrichers;
26+
private readonly LoggingSampler? _sampler;
2627
private readonly KeyValuePair<string, object?>[] _staticTags;
2728
private readonly Func<DataClassificationSet, Redactor> _redactorProvider;
2829
private volatile bool _disposed;
@@ -35,6 +36,7 @@ public ExtendedLoggerFactory(
3536
IEnumerable<ILogEnricher> enrichers,
3637
IEnumerable<IStaticLogEnricher> staticEnrichers,
3738
IOptionsMonitor<LoggerFilterOptions> filterOptions,
39+
LoggingSampler? sampler = null,
3840
IOptions<LoggerFactoryOptions>? factoryOptions = null,
3941
IExternalScopeProvider? scopeProvider = null,
4042
IOptionsMonitor<LoggerEnrichmentOptions>? enrichmentOptions = null,
@@ -43,6 +45,7 @@ public ExtendedLoggerFactory(
4345
#pragma warning restore S107 // Methods should not have too many parameters
4446
{
4547
_scopeProvider = scopeProvider;
48+
_sampler = sampler;
4649

4750
_factoryOptions = factoryOptions == null || factoryOptions.Value == null ? new LoggerFactoryOptions() : factoryOptions.Value;
4851

@@ -284,6 +287,7 @@ private LoggerConfig ComputeConfig(LoggerEnrichmentOptions? enrichmentOptions, L
284287

285288
return new(_staticTags,
286289
_enrichers,
290+
_sampler,
287291
enrichmentOptions.CaptureStackTraces,
288292
enrichmentOptions.UseFileInfoForStackTraces,
289293
enrichmentOptions.IncludeExceptionMessage,

src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ namespace Microsoft.Extensions.Logging
1717
{
1818
internal readonly struct MessageLogger
1919
{
20-
public MessageLogger(ILogger logger, string? category, string? providerTypeFullName, LogLevel? minLevel, Func<string?, string?, LogLevel, bool>? filter)
20+
public MessageLogger(ILogger logger, string category, string? providerTypeFullName, LogLevel? minLevel, Func<string?, string?, LogLevel, bool>? filter)
2121
{
2222
Logger = logger;
2323
Category = category;
@@ -36,7 +36,7 @@ public MessageLogger(ILogger logger, string? category, string? providerTypeFullN
3636

3737
public ILogger Logger { get; }
3838

39-
public string? Category { get; }
39+
public string Category { get; }
4040

4141
private string? ProviderTypeFullName { get; }
4242

src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ internal sealed class LoggerConfig
1515
public LoggerConfig(
1616
KeyValuePair<string, object?>[] staticTags,
1717
Action<IEnrichmentTagCollector>[] enrichers,
18+
LoggingSampler? sampler,
1819
bool captureStackTraces,
1920
bool useFileInfoForStackTraces,
2021
bool includeExceptionMessage,
@@ -25,6 +26,7 @@ public LoggerConfig(
2526
#pragma warning restore S107 // Methods should not have too many parameters
2627
StaticTags = staticTags;
2728
Enrichers = enrichers;
29+
Sampler = sampler;
2830
CaptureStackTraces = captureStackTraces;
2931
UseFileInfoForStackTraces = useFileInfoForStackTraces;
3032
MaxStackTraceLength = maxStackTraceLength;
@@ -35,6 +37,7 @@ public LoggerConfig(
3537

3638
public KeyValuePair<string, object?>[] StaticTags { get; }
3739
public Action<IEnrichmentTagCollector>[] Enrichers { get; }
40+
public LoggingSampler? Sampler { get; }
3841
public bool CaptureStackTraces { get; }
3942
public bool UseFileInfoForStackTraces { get; }
4043
public bool IncludeExceptionMessage { get; }

src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
<PropertyGroup>
99
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
10+
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
11+
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
1012
<InjectSharedText>true</InjectSharedText>
1113
<InjectSharedDataValidation>true</InjectSharedDataValidation>
1214
<InjectSharedNumericExtensions>true</InjectSharedNumericExtensions>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace Microsoft.Extensions.Diagnostics.Sampling;
7+
8+
/// <summary>
9+
/// Represents a rule used for filtering log messages for purposes of log sampling and buffering.
10+
/// </summary>
11+
internal interface ILogSamplingFilterRule
12+
{
13+
/// <summary>
14+
/// Gets the logger category this rule applies to.
15+
/// </summary>
16+
string? CategoryName { get; }
17+
18+
/// <summary>
19+
/// Gets the maximum <see cref="LogLevel"/> of messages.
20+
/// </summary>
21+
LogLevel? LogLevel { get; }
22+
23+
/// <summary>
24+
/// Gets the event ID this rule applies to.
25+
/// </summary>
26+
int? EventId { get; }
27+
28+
/// <summary>
29+
/// Gets the name of the event this rule applies to.
30+
/// </summary>
31+
string? EventName { get; }
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#pragma warning disable CA1307 // Specify StringComparison for clarity
5+
#pragma warning disable S1659 // Multiple variables should not be declared on the same line
6+
#pragma warning disable S2302 // "nameof" should be used
7+
8+
using System;
9+
using System.Collections.Concurrent;
10+
using System.Collections.Generic;
11+
using Microsoft.Extensions.Diagnostics.Sampling;
12+
using Microsoft.Extensions.Logging;
13+
14+
namespace Microsoft.Extensions.Diagnostics.Sampling;
15+
16+
internal sealed class LogSamplingRuleSelector<T>
17+
where T : class, ILogSamplingFilterRule
18+
{
19+
private readonly ConcurrentDictionary<(string, LogLevel, EventId), T?> _ruleCache = new();
20+
21+
public void InvalidateCache()
22+
{
23+
_ruleCache.Clear();
24+
}
25+
26+
public T? Select(IList<T> rules, string category, LogLevel logLevel, EventId eventId)
27+
{
28+
return _ruleCache.GetOrAdd((category, logLevel, eventId), _ =>
29+
{
30+
// Filter rule selection:
31+
// 0. Ignore rules whose LogLevel is defined but lower than the requested logLevel
32+
// 1. Ignore rules whose EventId is defined but different from the requested eventId
33+
// 2. For category filtering, handle optional wildcards (only one '*' allowed) and match the prefix/suffix ignoring case
34+
// 3. Out of the matched set, pick the rule with the longest matching category
35+
// 4. If no rules match by category, accept rules without a category
36+
// 5. If exactly one rule remains, use it; if multiple remain, select the last in the list
37+
T? current = null;
38+
foreach (T rule in rules)
39+
{
40+
if (IsBetter(rule, current, category, logLevel, eventId))
41+
{
42+
current = rule;
43+
}
44+
}
45+
46+
return current;
47+
});
48+
}
49+
50+
private static bool IsBetter(T rule, T? current, string category, LogLevel logLevel, EventId eventId)
51+
{
52+
// Skip rules with inapplicable log level
53+
if (rule.LogLevel is not null && rule.LogLevel < logLevel)
54+
{
55+
return false;
56+
}
57+
58+
// Skip rules with inapplicable event id
59+
if (rule.EventId is not null && rule.EventId != eventId)
60+
{
61+
return false;
62+
}
63+
64+
// Skip rules with inapplicable category
65+
string? categoryName = rule.CategoryName;
66+
if (categoryName is not null)
67+
{
68+
const char WildcardChar = '*';
69+
70+
int wildcardIndex = categoryName.IndexOf(WildcardChar);
71+
if (wildcardIndex >= 0 &&
72+
categoryName.IndexOf(WildcardChar, wildcardIndex + 1) >= 0)
73+
{
74+
throw new InvalidOperationException("Only one wildcard character is allowed in category name.");
75+
}
76+
77+
ReadOnlySpan<char> prefix, suffix;
78+
if (wildcardIndex == -1)
79+
{
80+
prefix = categoryName.AsSpan();
81+
suffix = default;
82+
}
83+
else
84+
{
85+
prefix = categoryName.AsSpan(0, wildcardIndex);
86+
suffix = categoryName.AsSpan(wildcardIndex + 1);
87+
}
88+
89+
if (!category.AsSpan().StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
90+
!category.AsSpan().EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
91+
{
92+
return false;
93+
}
94+
}
95+
96+
// Decide whose category is better - rule vs current
97+
if (current?.CategoryName is not null)
98+
{
99+
if (rule.CategoryName is null)
100+
{
101+
return false;
102+
}
103+
104+
if (current.CategoryName.Length > rule.CategoryName.Length)
105+
{
106+
return false;
107+
}
108+
}
109+
110+
// Decide whose log level is better - rule vs current
111+
if (current?.LogLevel is not null)
112+
{
113+
if (rule.LogLevel is null)
114+
{
115+
return false;
116+
}
117+
118+
if (current.LogLevel < rule.LogLevel)
119+
{
120+
return false;
121+
}
122+
}
123+
124+
// Decide whose event id is better - rule vs current
125+
if (rule.EventId is null)
126+
{
127+
if (current?.EventId is not null)
128+
{
129+
return false;
130+
}
131+
}
132+
133+
return true;
134+
}
135+
}

0 commit comments

Comments
 (0)