diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj b/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj index bbd90f7f5ee0ec..ce25d3c2a8ddc4 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj @@ -55,6 +55,7 @@ System.Diagnostics.DiagnosticSource + diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesFilterAndTransform.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesFilterAndTransform.cs index b16e513e924e90..be75ba9db2d82d 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesFilterAndTransform.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesFilterAndTransform.cs @@ -27,6 +27,7 @@ internal sealed class DsesFilterAndTransform : IDisposable { private const string c_ActivitySourcePrefix = "[AS]"; private const string c_ParentRatioSamplerPrefix = "ParentRatioSampler("; + private const string c_ParentRateLimitingSamplerPrefix = "ParentRateLimitingSampler("; /// /// Parses filterAndPayloadSpecs which is a list of lines each of which has the from @@ -262,6 +263,29 @@ private static bool IsActivitySourceEntry(string filterAndPayloadSpec, int start sampleFunc = DsesSamplerBuilder.CreateParentRatioSampler(ratio); } + else if (suffixPart.StartsWith(c_ParentRateLimitingSamplerPrefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + int endingLocation = suffixPart.IndexOf(')'); + if (endingLocation < 0 + || !int.TryParse( +#if NETFRAMEWORK || NETSTANDARD + suffixPart.Slice(c_ParentRateLimitingSamplerPrefix.Length, endingLocation - c_ParentRateLimitingSamplerPrefix.Length).ToString(), +#else + suffixPart.Slice(c_ParentRateLimitingSamplerPrefix.Length, endingLocation - c_ParentRateLimitingSamplerPrefix.Length), +#endif + NumberStyles.None, CultureInfo.InvariantCulture, out int maximumRatePerSecond) + || maximumRatePerSecond <= 0) + { + if (eventSource.IsEnabled(EventLevel.Warning, DiagnosticSourceEventSource.Keywords.Messages)) + { + eventSource.Message("DiagnosticSource: Ignoring filterAndPayloadSpec '[AS]" + entry.ToString() + "' because rate limiting sampling was invalid"); + } + + return next; + } + + sampleFunc = DsesSamplerBuilder.CreateParentRateLimitingSampler(maximumRatePerSecond); + } else { if (eventSource.IsEnabled(EventLevel.Warning, DiagnosticSourceEventSource.Keywords.Messages)) diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesSamplerBuilder.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesSamplerBuilder.cs index 4f404784929218..e10016a02c1026 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesSamplerBuilder.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DsesSamplerBuilder.cs @@ -66,4 +66,25 @@ static long GetLowerLong(ReadOnlySpan bytes) return result; } } + + public static DsesSampleActivityFunc CreateParentRateLimitingSampler(int maximumRatePerSecond) + { + Debug.Assert(maximumRatePerSecond > 0, "maximumRatePerSecond must be greater than 0"); + + RateLimiter rateLimiter = new RateLimiter(maximumRatePerSecond); + + return (bool hasActivityContext, ref ActivityCreationOptions options) => + { + if (hasActivityContext && options.TraceId != default) + { + ActivityContext parentContext = options.Parent; + + return parentContext == default || parentContext.IsRemote ? // root or remote activity + (rateLimiter.TryAcquire() ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None) : + parentContext.TraceFlags.HasFlag(ActivityTraceFlags.Recorded) ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None; + } + + return ActivitySamplingResult.None; + }; + } } diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/RateLimiter.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/RateLimiter.cs new file mode 100644 index 00000000000000..742af13652a819 --- /dev/null +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/RateLimiter.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; + +namespace System.Diagnostics +{ + /// + /// A class that limits operations to a maximum count per second. + /// + internal sealed class RateLimiter + { + private readonly int _maxOperationsPerSecond; + private readonly Stopwatch _stopwatch; + private readonly long _ticksPerSecond; + private int _currentOperationCount; + private long _intervalStartTicks; + + /// + /// Initializes a new instance of the RateLimiter class. + /// + /// Maximum number of operations allowed per second. + internal RateLimiter(int maxOperationsPerSecond) + { + Debug.Assert(maxOperationsPerSecond > 0, "maxOperationsPerSecond must be greater than 0"); + + _maxOperationsPerSecond = maxOperationsPerSecond; + _stopwatch = new Stopwatch(); + _stopwatch.Start(); + _intervalStartTicks = _stopwatch.ElapsedTicks; + _currentOperationCount = 0; + _ticksPerSecond = Stopwatch.Frequency; + } + + /// + /// Tries to perform an operation if the rate limit allows it. + /// + /// True if the operation is allowed within the rate limit, otherwise false. + internal bool TryAcquire() + { + do + { + long currentTicks = _stopwatch.ElapsedTicks; + long intervalStartTicks = Interlocked.Read(ref _intervalStartTicks); + int currentOperationCount = Volatile.Read(ref _currentOperationCount); + long elapsedTicks = currentTicks - intervalStartTicks; + + // If a second has elapsed, reset the counter + if (elapsedTicks >= _ticksPerSecond) + { + if (Interlocked.CompareExchange(ref _currentOperationCount, 1, currentOperationCount) != currentOperationCount) + { + // Another thread has already reset the counter, so we need to check again + continue; + } + + // Update the _intervalStartTicks if no-one else updated it in the meantime. + Interlocked.CompareExchange(ref _intervalStartTicks, currentTicks, intervalStartTicks); + + return true; // Allow the operation + } + + return Interlocked.Increment(ref _currentOperationCount) <= _maxOperationsPerSecond; + } while (true); + } + } +} diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/tests/DiagnosticSourceEventSourceBridgeTests.cs b/src/libraries/System.Diagnostics.DiagnosticSource/tests/DiagnosticSourceEventSourceBridgeTests.cs index d3f0f005c96a0c..cc4062169dd77a 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/tests/DiagnosticSourceEventSourceBridgeTests.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/DiagnosticSourceEventSourceBridgeTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.Tracing; +using System.Globalization; using System.Reflection; using System.Text; using System.Threading; @@ -1441,6 +1442,55 @@ public void TestMultipleRulesOnlyFirstTaken(string spec, string errorMessage) }, spec, errorMessage).Dispose(); } + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(1)] + [InlineData(5)] + [InlineData(50)] + [InlineData(100)] + public void TestRateLimitingSampler(int maxOperationPerSecond) + { + RemoteExecutor.Invoke((maxOpPerSecond) => + { + // Test RateLimitingSampler behavior + using TestDiagnosticSourceEventListener eventSourceListener = new TestDiagnosticSourceEventListener(); + Activity a = new Activity(""); // we need this to ensure DiagnosticSourceEventSource.Logger creation. + Assert.Equal("", a.Source.Name); + + Assert.Equal(0, eventSourceListener.EventCount); + + Stopwatch sw = new Stopwatch(); + sw.Start(); + + eventSourceListener.Enable($"[AS]*/-ParentRateLimitingSampler({maxOpPerSecond})"); + + while (sw.ElapsedMilliseconds < 3000) // run for 3 seconds + { + // ensure we are creating a root activity + Activity.Current = null; + using var nextRoot = a.Source.StartActivity("NextRoot"); + } + + int maxOps = int.Parse(maxOpPerSecond, CultureInfo.InvariantCulture); + // maxOperationPerSecond sampling allowed per second + // 2 events for every sampling (activity start and stop) + // 3 seconds of sampling + // tolerance of extra sample can be done if the second turn after the loop check sw.ElapsedMilliseconds. 2 extra events (start and stop). + Assert.True(maxOps * 2 * 3 + 2 >= eventSourceListener.EventCount, $"{eventSourceListener.EventCount} events were recorded, while maxOpPerSecond is {maxOpPerSecond}"); + + // Ensure the minimum number of events is recorded in 3 seconds. We are choosing reasonable small number as it depends on the machine speed. + Assert.True(eventSourceListener.EventCount >= 6, $"{eventSourceListener.EventCount} events were recorded, while maxOpPerSecond is {maxOpPerSecond}"); + + Thread.Sleep(1000); // ensure new allowance for root creation + Activity.Current = null; + using var root = a.Source.StartActivity("root"); + Assert.NotNull(root); + Assert.True(root.Recorded); + + using var child = a.Source.StartActivity("child"); + Assert.NotNull(child); // Child should be created as the parent is recorded. + }, maxOperationPerSecond.ToString(CultureInfo.InvariantCulture)).Dispose(); + } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] public void TestParentRatioSampler() { diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/tests/System.Diagnostics.DiagnosticSource.Tests.csproj b/src/libraries/System.Diagnostics.DiagnosticSource/tests/System.Diagnostics.DiagnosticSource.Tests.csproj index 1cfd527b7acb08..9547084315e3db 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/tests/System.Diagnostics.DiagnosticSource.Tests.csproj +++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/System.Diagnostics.DiagnosticSource.Tests.csproj @@ -23,6 +23,7 @@ +