diff --git a/docs/core/logging.md b/docs/core/logging.md index c3932e2a5..a074e9937 100644 --- a/docs/core/logging.md +++ b/docs/core/logging.md @@ -778,12 +778,16 @@ custom keys can be persisted across invocations. If you want all custom keys to ## Sampling debug logs -You can dynamically set a percentage of your logs to **DEBUG** level via env var `POWERTOOLS_LOGGER_SAMPLE_RATE` or -via `SamplingRate` parameter on attribute. +Use sampling when you want to dynamically change your log level to **DEBUG** based on a **percentage of the Lambda function invocations**. -!!! info - Configuration on environment variable is given precedence over sampling rate configuration on attribute, provided it's - in valid value range. +You can use values ranging from `0.0` to `1` (100%) when setting `POWERTOOLS_LOGGER_SAMPLE_RATE` env var, or `SamplingRate` parameter in Logger. + +???+ tip "Tip: When is this useful?" + Log sampling allows you to capture debug information for a fraction of your requests, helping you diagnose rare or intermittent issues without increasing the overall verbosity of your logs. + + Example: Imagine an e-commerce checkout process where you want to understand rare payment gateway errors. With 10% sampling, you'll log detailed information for a small subset of transactions, making troubleshooting easier without generating excessive logs. + +The sampling decision happens automatically with each invocation when using `Logger` decorator. When not using the decorator, you're in charge of refreshing it via `RefreshSampleRateCalculation` method. Skipping both may lead to unexpected sampling results. === "Sampling via attribute parameter" @@ -802,6 +806,30 @@ via `SamplingRate` parameter on attribute. } ``` +=== "Sampling Logger.Configure" + + ```c# hl_lines="5-10 16" + public class Function + { + public Function() + { + Logger.Configure(options => + { + options.MinimumLogLevel = LogLevel.Information; + options.LoggerOutputCase = LoggerOutputCase.CamelCase; + options.SamplingRate = 0.1; // 10% sampling + }); + } + + public async Task FunctionHandler + (APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Logger.RefreshSampleRateCalculation(); + ... + } + } + ``` + === "Sampling via environment variable" ```yaml hl_lines="8" diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/Helpers/LoggerFactoryHelper.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/Helpers/LoggerFactoryHelper.cs index 9ce483f86..17fc402ac 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/Helpers/LoggerFactoryHelper.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/Helpers/LoggerFactoryHelper.cs @@ -21,6 +21,7 @@ internal static ILoggerFactory CreateAndConfigureFactory(PowertoolsLoggerConfigu config.Service = configuration.Service; config.TimestampFormat = configuration.TimestampFormat; config.MinimumLogLevel = configuration.MinimumLogLevel; + config.InitialLogLevel = configuration.InitialLogLevel; config.SamplingRate = configuration.SamplingRate; config.LoggerOutputCase = configuration.LoggerOutputCase; config.LogLevelKey = configuration.LogLevelKey; @@ -32,8 +33,14 @@ internal static ILoggerFactory CreateAndConfigureFactory(PowertoolsLoggerConfigu config.LogEvent = configuration.LogEvent; }); - // Use current filter level or level from config - if (configuration.MinimumLogLevel != LogLevel.None) + // When sampling is enabled, set the factory minimum level to Debug + // so that all logs can reach our PowertoolsLogger for dynamic filtering + if (configuration.SamplingRate > 0) + { + builder.AddFilter(null, LogLevel.Debug); + builder.SetMinimumLevel(LogLevel.Debug); + } + else if (configuration.MinimumLogLevel != LogLevel.None) { builder.AddFilter(null, configuration.MinimumLogLevel); builder.SetMinimumLevel(configuration.MinimumLogLevel); diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLogger.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLogger.cs index 525a26cb8..6ad3d34c2 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLogger.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLogger.cs @@ -80,7 +80,18 @@ internal void EndScope() public bool IsEnabled(LogLevel logLevel) { var config = _currentConfig(); + return IsEnabledForConfig(logLevel, config); + } + /// + /// Determines whether the specified log level is enabled for a specific configuration. + /// + /// The log level. + /// The configuration to check against. + /// bool. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsEnabledForConfig(LogLevel logLevel, PowertoolsLoggerConfiguration config) + { //if Buffering is enabled and the log level is below the buffer threshold, skip logging only if bellow error if (logLevel <= config.LogBuffering?.BufferAtLogLevel && config.LogBuffering?.BufferAtLogLevel != LogLevel.Error @@ -116,12 +127,23 @@ public bool IsEnabled(LogLevel logLevel) public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) { - if (!IsEnabled(logLevel)) + var config = _currentConfig(); + if (config.SamplingRate > 0) + { + var samplingActivated = config.RefreshSampleRateCalculation(out double samplerValue); + if (samplingActivated) + { + config.LogOutput.WriteLine($"Changed log level to DEBUG based on Sampling configuration. Sampling Rate: {config.SamplingRate}, Sampler Value: {samplerValue}."); + } + } + + // Use the same config reference for IsEnabled check to ensure we see the updated MinimumLogLevel + if (!IsEnabledForConfig(logLevel, config)) { return; } - _currentConfig().LogOutput.WriteLine(LogEntryString(logLevel, state, exception, formatter)); + config.LogOutput.WriteLine(LogEntryString(logLevel, state, exception, formatter)); } internal void LogLine(string message) @@ -129,6 +151,14 @@ internal void LogLine(string message) _currentConfig().LogOutput.WriteLine(message); } + private void LogDebug(string message) + { + if (IsEnabled(LogLevel.Debug)) + { + Log(LogLevel.Debug, new EventId(), message, null, (msg, ex) => msg); + } + } + internal string LogEntryString(LogLevel logLevel, TState state, Exception exception, Func formatter) { @@ -662,4 +692,4 @@ private string ExtractParameterName(string key) ? nameWithPossibleFormat.Substring(0, colonIndex) : nameWithPossibleFormat; } -} \ No newline at end of file +} diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLoggerProvider.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLoggerProvider.cs index 8c16a0ac5..66536527a 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLoggerProvider.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLoggerProvider.cs @@ -24,10 +24,10 @@ public PowertoolsLoggerProvider( { _powertoolsConfigurations = powertoolsConfigurations; _currentConfig = config; - + // Set execution environment _powertoolsConfigurations.SetExecutionEnvironment(this); - + // Apply environment configurations if available ConfigureFromEnvironment(); } @@ -58,47 +58,41 @@ public void ConfigureFromEnvironment() _currentConfig.LoggerOutputCase = loggerOutputCase; } - // Set log level from environment ONLY if not explicitly set var minLogLevel = lambdaLogLevelEnabled ? lambdaLogLevel : logLevel; - _currentConfig.MinimumLogLevel = minLogLevel != LogLevel.None ? minLogLevel : LoggingConstants.DefaultLogLevel; + var effectiveLogLevel = minLogLevel != LogLevel.None ? minLogLevel : LoggingConstants.DefaultLogLevel; + + // Only set InitialLogLevel if it hasn't been explicitly configured + if (_currentConfig.InitialLogLevel == LogLevel.Information) + { + _currentConfig.InitialLogLevel = effectiveLogLevel; + } + + _currentConfig.MinimumLogLevel = effectiveLogLevel; + _currentConfig.XRayTraceId = _powertoolsConfigurations.XRayTraceId; _currentConfig.LogEvent = _powertoolsConfigurations.LoggerLogEvent; - + // Configure the log level key based on output case _currentConfig.LogLevelKey = _powertoolsConfigurations.LambdaLogLevelEnabled() && _currentConfig.LoggerOutputCase == LoggerOutputCase.PascalCase ? "LogLevel" : LoggingConstants.KeyLogLevel; - + ProcessSamplingRate(_currentConfig, _powertoolsConfigurations); _environmentConfigured = true; } - + /// /// Process sampling rate configuration /// private void ProcessSamplingRate(PowertoolsLoggerConfiguration config, IPowertoolsConfigurations configurations) { - var samplingRate = config.SamplingRate > 0 - ? config.SamplingRate + var samplingRate = config.SamplingRate > 0 + ? config.SamplingRate : configurations.LoggerSampleRate; - + samplingRate = ValidateSamplingRate(samplingRate, config); config.SamplingRate = samplingRate; - - // Only notify if sampling is configured - if (samplingRate > 0) - { - double sample = config.GetRandom(); - - // Instead of changing log level, just indicate sampling status - if (sample <= samplingRate) - { - config.LogOutput.WriteLine( - $"Changed log level to DEBUG based on Sampling configuration. Sampling Rate: {samplingRate}, Sampler Value: {sample}."); - config.MinimumLogLevel = LogLevel.Debug; - } - } } /// @@ -129,11 +123,11 @@ public virtual ILogger CreateLogger(string categoryName) } internal PowertoolsLoggerConfiguration GetCurrentConfig() => _currentConfig; - + public void UpdateConfiguration(PowertoolsLoggerConfiguration config) { _currentConfig = config; - + // Apply environment configurations if available if (_powertoolsConfigurations != null && !_environmentConfigured) { @@ -141,6 +135,15 @@ public void UpdateConfiguration(PowertoolsLoggerConfiguration config) } } + /// + /// Refresh the sampling calculation and update the minimum log level if needed + /// + /// True if debug sampling was enabled, false otherwise + internal bool RefreshSampleRateCalculation() + { + return _currentConfig.RefreshSampleRateCalculation(); + } + public virtual void Dispose() { _loggers.Clear(); diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Sampling.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Sampling.cs new file mode 100644 index 000000000..0353df181 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Sampling.cs @@ -0,0 +1,13 @@ +namespace AWS.Lambda.Powertools.Logging; + +public static partial class Logger +{ + /// + /// Refresh the sampling calculation and update the minimum log level if needed + /// + /// True if debug sampling was enabled, false otherwise + public static bool RefreshSampleRateCalculation() + { + return _config.RefreshSampleRateCalculation(); + } +} diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Logger.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Logger.cs index 77f1aebc5..91d786d4f 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/Logger.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Logger.cs @@ -12,6 +12,7 @@ namespace AWS.Lambda.Powertools.Logging; /// public static partial class Logger { + private static PowertoolsLoggerConfiguration _config; private static ILogger _loggerInstance; private static readonly object Lock = new object(); @@ -58,9 +59,9 @@ public static void Configure(Action configure) { lock (Lock) { - var config = new PowertoolsLoggerConfiguration(); - configure(config); - _loggerInstance = LoggerFactoryHelper.CreateAndConfigureFactory(config).CreatePowertoolsLogger(); + _config = new PowertoolsLoggerConfiguration(); + configure(_config); + _loggerInstance = LoggerFactoryHelper.CreateAndConfigureFactory(_config).CreatePowertoolsLogger(); } } diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerConfiguration.cs b/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerConfiguration.cs index 9a28bd1a5..9b25d6539 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerConfiguration.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerConfiguration.cs @@ -313,7 +313,8 @@ private PowertoolsLoggingSerializer InitializeSerializer() internal string XRayTraceId { get; set; } internal bool LogEvent { get; set; } - internal double Random { get; set; } = GetSafeRandom(); + internal int SamplingRefreshCount { get; set; } = 0; + internal LogLevel InitialLogLevel { get; set; } = LogLevel.Information; /// /// Gets random number @@ -321,14 +322,75 @@ private PowertoolsLoggingSerializer InitializeSerializer() /// System.Double. internal virtual double GetRandom() { - return Random; + return GetSafeRandom(); } - + + /// + /// Refresh the sampling calculation and update the minimum log level if needed + /// + /// True if debug sampling was enabled, false otherwise + internal bool RefreshSampleRateCalculation() + { + return RefreshSampleRateCalculation(out _); + } + + /// + /// Refresh the sampling calculation and update the minimum log level if needed + /// + /// + /// True if debug sampling was enabled, false otherwise + internal bool RefreshSampleRateCalculation(out double samplerValue) + { + samplerValue = 0.0; + + if (SamplingRate <= 0) + return false; + + // Increment counter at the beginning for proper cold start protection + SamplingRefreshCount++; + + // Skip first call for cold start protection + if (SamplingRefreshCount == 1) + { + return false; + } + + var shouldEnableDebugSampling = ShouldEnableDebugSampling(out samplerValue); + + if (shouldEnableDebugSampling && MinimumLogLevel > LogLevel.Debug) + { + MinimumLogLevel = LogLevel.Debug; + return true; + } + else if (!shouldEnableDebugSampling) + { + MinimumLogLevel = InitialLogLevel; + } + + return shouldEnableDebugSampling; + } + + + internal bool ShouldEnableDebugSampling() + { + return ShouldEnableDebugSampling(out _); + } + + internal bool ShouldEnableDebugSampling(out double samplerValue) + { + samplerValue = 0.0; + if (SamplingRate <= 0) return false; + + samplerValue = GetRandom(); + return samplerValue <= SamplingRate; + } + internal static double GetSafeRandom() { var randomGenerator = RandomNumberGenerator.Create(); - byte[] data = new byte[16]; + byte[] data = new byte[4]; randomGenerator.GetBytes(data); - return BitConverter.ToDouble(data); + uint randomUInt = BitConverter.ToUInt32(data, 0); + return (double)randomUInt / uint.MaxValue; } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs b/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs index a19456131..9a6cda819 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs @@ -257,4 +257,13 @@ public static void ClearBuffer(this ILogger logger) // Direct call to the buffer manager to avoid any recursion LogBufferManager.ClearCurrentBuffer(); } + + /// + /// Refresh the sampling calculation and update the minimum log level if needed + /// + /// + public static bool RefreshSampleRateCalculation(this ILogger logger) + { + return Logger.RefreshSampleRateCalculation(); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/HandlerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/HandlerTests.cs index c58c75ca4..0046ce5c8 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/HandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/HandlerTests.cs @@ -426,6 +426,136 @@ public void TestJsonOptionsWriteIndented() Assert.Contains("\n", logOutput); Assert.Contains(" ", logOutput); } + + /// + /// Test sampling behavior with environment variables using the [Logging] attribute + /// POWERTOOLS_LOG_LEVEL=Error and POWERTOOLS_LOGGER_SAMPLE_RATE=0.9 + /// + [Fact] + public void EnvironmentVariableSampling_HandlerWithSampling_ShouldElevateInfoLogs() + { + // Arrange - Set environment variables for sampling test + var originalLogLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + var originalSampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + try + { + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", "Error"); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", "0.9"); + + var output = new TestLoggerOutput(); + Logger.Configure(options => { options.LogOutput = output; }); + + var handler = new EnvironmentVariableSamplingHandler(); + + // Act - Try multiple times to trigger sampling (90% chance each time) + bool samplingTriggered = false; + string logOutput = ""; + + // Try up to 20 times to trigger sampling + for (int i = 0; i < 20 && !samplingTriggered; i++) + { + output.Clear(); + Logger.Reset(); + Logger.Configure(options => { options.LogOutput = output; }); + + handler.HandleWithSampling(new string[] { }); + + logOutput = output.ToString(); + samplingTriggered = logOutput.Contains("Changed log level to DEBUG based on Sampling configuration"); + } + + // Assert + Assert.True(samplingTriggered, "Sampling should have been triggered within 20 attempts with 90% rate"); + Assert.Contains("This is an info message — should not appear", logOutput); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", originalLogLevel); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", originalSampleRate); + Logger.Reset(); + } + } + + /// + /// Test with 100% sampling rate to guarantee sampling works consistently + /// + [Fact] + public void EnvironmentVariableSampling_HandlerWithFullSampling_ShouldAlwaysElevateInfoLogs() + { + // Arrange + var originalLogLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + var originalSampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + try + { + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", "Error"); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", "1.0"); + + var output = new TestLoggerOutput(); + Logger.Configure(options => { options.LogOutput = output; }); + + var handler = new EnvironmentVariableSamplingHandler(); + + // Act + handler.HandleWithFullSampling(new string[] { }); + + // Assert + var logOutput = output.ToString(); + _output.WriteLine(logOutput); + + Assert.Contains("Changed log level to DEBUG based on Sampling configuration", logOutput); + Assert.Contains("This is an info message — should appear with 100% sampling", logOutput); + Assert.Contains("\"service\":\"HelloWorldService\"", logOutput); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", originalLogLevel); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", originalSampleRate); + Logger.Reset(); + } + } + + /// + /// Test with 0% sampling rate to ensure info logs are not elevated + /// + [Fact] + public void EnvironmentVariableSampling_HandlerWithNoSampling_ShouldNotElevateInfoLogs() + { + // Arrange + var originalLogLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + var originalSampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + try + { + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", "Error"); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", "0"); + + var output = new TestLoggerOutput(); + Logger.Configure(options => { options.LogOutput = output; }); + + var handler = new EnvironmentVariableSamplingHandler(); + + // Act + handler.HandleWithNoSampling(new string[] { }); + + // Assert + var logOutput = output.ToString(); + _output.WriteLine(logOutput); + + Assert.DoesNotContain("Changed log level to DEBUG based on Sampling configuration", logOutput); + Assert.DoesNotContain("This is an info message — should NOT appear with 0% sampling", logOutput); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", originalLogLevel); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", originalSampleRate); + Logger.Reset(); + } + } } #endif diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs index fe4edd2f7..dd332ea4c 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs @@ -240,4 +240,39 @@ public async Task AsyncException() throw new Exception(); } +} + +public class EnvironmentVariableSamplingHandler +{ + /// + /// Handler that tests sampling behavior with environment variables: + /// Using environment variables POWERTOOLS_LOG_LEVEL=Error and POWERTOOLS_LOGGER_SAMPLE_RATE=0.9 + /// + [Logging(Service = "HelloWorldService", LoggerOutputCase = LoggerOutputCase.CamelCase, LogEvent = true)] + public void HandleWithSampling(string[] args) + { + var logLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + var sampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + // This should NOT be logged (Info < Error) unless sampling elevates the log level + Logger.LogInformation("This is an info message — should not appear"); + } + + /// + /// Handler for testing with guaranteed sampling (100%) + /// + [Logging(Service = "HelloWorldService", LoggerOutputCase = LoggerOutputCase.CamelCase, LogEvent = true)] + public void HandleWithFullSampling(string[] args) + { + Logger.LogInformation("This is an info message — should appear with 100% sampling"); + } + + /// + /// Handler for testing with no sampling (0%) + /// + [Logging(Service = "HelloWorldService", LoggerOutputCase = LoggerOutputCase.CamelCase, LogEvent = true)] + public void HandleWithNoSampling(string[] args) + { + Logger.LogInformation("This is an info message — should NOT appear with 0% sampling"); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/PowertoolsLoggerTest.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/PowertoolsLoggerTest.cs index 1aff9187a..05141510f 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/PowertoolsLoggerTest.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/PowertoolsLoggerTest.cs @@ -297,8 +297,7 @@ public void Log_SamplingRateGreaterThanRandom_ChangedLogLevelToDebug() // Arrange var service = Guid.NewGuid().ToString(); var logLevel = LogLevel.Trace; - var loggerSampleRate = 0.7; - var randomSampleRate = 0.5; + var loggerSampleRate = 1.0; // Use 100% to guarantee activation var configurations = Substitute.For(); configurations.Service.Returns(service); @@ -312,22 +311,24 @@ public void Log_SamplingRateGreaterThanRandom_ChangedLogLevelToDebug() Service = service, MinimumLogLevel = logLevel, LogOutput = systemWrapper, - SamplingRate = loggerSampleRate, - Random = randomSampleRate + SamplingRate = loggerSampleRate }; // Act var provider = new PowertoolsLoggerProvider(loggerConfiguration, configurations); - var logger = provider.CreateLogger("test"); - logger.LogInformation("Test"); + // First call - skipped due to cold start protection + logger.LogInformation("Test1"); + + // Second call - should trigger sampling with 100% rate + logger.LogInformation("Test2"); - // Assert + // Assert - Check that the debug message was printed (with any sampler value since it's random) systemWrapper.Received(1).WriteLine( Arg.Is(s => - s == - $"Changed log level to DEBUG based on Sampling configuration. Sampling Rate: {loggerSampleRate}, Sampler Value: {randomSampleRate}." + s.Contains("Changed log level to DEBUG based on Sampling configuration") && + s.Contains($"Sampling Rate: {loggerSampleRate}") ) ); } @@ -1454,8 +1455,7 @@ public void Log_Should_Serialize_TimeOnly() Service = null, MinimumLogLevel = LogLevel.None, LoggerOutputCase = LoggerOutputCase.CamelCase, - LogOutput = systemWrapper, - Random = randomSampleRate + LogOutput = systemWrapper }; var provider = new PowertoolsLoggerProvider(loggerConfiguration, configurations); @@ -1793,10 +1793,114 @@ public void Log_WhenDuplicateKeysInState_LastValueWins() systemWrapper.Received(1).WriteLine(Arg.Any()); } + [Fact] + public void GetSafeRandom_ShouldReturnValueBetweenZeroAndOne() + { + // Act & Assert - Test multiple times to ensure consistency + for (int i = 0; i < 1000; i++) + { + var randomValue = PowertoolsLoggerConfiguration.GetSafeRandom(); + + Assert.True(randomValue >= 0.0, $"Random value {randomValue} should be >= 0.0"); + Assert.True(randomValue <= 1.0, $"Random value {randomValue} should be <= 1.0"); + } + } + + [Fact] + public void GetSafeRandom_ShouldReturnDifferentValues() + { + // Arrange + var values = new HashSet(); + + // Act - Generate multiple random values + for (int i = 0; i < 100; i++) + { + values.Add(PowertoolsLoggerConfiguration.GetSafeRandom()); + } + + // Assert - Should have generated multiple different values + Assert.True(values.Count > 50, "Should generate diverse random values"); + } + + [Fact] + public void Log_SamplingWithRealRandomGenerator_ShouldWorkCorrectly() + { + // Arrange + var service = Guid.NewGuid().ToString(); + var logLevel = LogLevel.Error; // Set high log level + var loggerSampleRate = 1.0; // 100% sampling rate to ensure activation + + var configurations = Substitute.For(); + configurations.Service.Returns(service); + configurations.LogLevel.Returns(logLevel.ToString()); + configurations.LoggerSampleRate.Returns(loggerSampleRate); + + var systemWrapper = Substitute.For(); + + var loggerConfiguration = new PowertoolsLoggerConfiguration + { + Service = service, + MinimumLogLevel = logLevel, + LogOutput = systemWrapper, + SamplingRate = loggerSampleRate + }; + + // Act + var provider = new PowertoolsLoggerProvider(loggerConfiguration, configurations); + var logger = provider.CreateLogger("test"); + + // First call - skipped due to cold start protection + logger.LogError("Test1"); + + // Second call - should trigger sampling with 100% rate + logger.LogError("Test2"); + + // Assert - With 100% sampling rate, should always activate sampling + systemWrapper.Received(1).WriteLine( + Arg.Is(s => + s.Contains("Changed log level to DEBUG based on Sampling configuration") && + s.Contains($"Sampling Rate: {loggerSampleRate}") + ) + ); + } + + [Fact] + public void Log_SamplingWithZeroRate_ShouldNeverActivate() + { + // Arrange + var service = Guid.NewGuid().ToString(); + var logLevel = LogLevel.Error; + var loggerSampleRate = 0.0; // 0% sampling rate + + var configurations = Substitute.For(); + configurations.Service.Returns(service); + configurations.LogLevel.Returns(logLevel.ToString()); + configurations.LoggerSampleRate.Returns(loggerSampleRate); + + var systemWrapper = Substitute.For(); + + var loggerConfiguration = new PowertoolsLoggerConfiguration + { + Service = service, + MinimumLogLevel = logLevel, + LogOutput = systemWrapper, + SamplingRate = loggerSampleRate + }; + + // Act + var provider = new PowertoolsLoggerProvider(loggerConfiguration, configurations); + var logger = provider.CreateLogger("test"); + + // Assert - With 0% sampling rate, should never activate sampling + systemWrapper.DidNotReceive().WriteLine( + Arg.Is(s => s.Contains("Changed log level to DEBUG based on Sampling configuration")) + ); + } + public void Dispose() { // Environment.SetEnvironmentVariable("AWS_LAMBDA_INITIALIZATION_TYPE", null); LambdaLifecycleTracker.Reset(); } } -} \ No newline at end of file +} diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Sampling/EnvironmentVariableSamplingTests.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Sampling/EnvironmentVariableSamplingTests.cs new file mode 100644 index 000000000..03d191ecd --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Sampling/EnvironmentVariableSamplingTests.cs @@ -0,0 +1,260 @@ +using System; +using AWS.Lambda.Powertools.Common; +using AWS.Lambda.Powertools.Common.Tests; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace AWS.Lambda.Powertools.Logging.Tests; + +/// +/// Tests for sampling behavior when using environment variables +/// This covers the specific use case described in the GitHub issue +/// +public class EnvironmentVariableSamplingTests : IDisposable +{ + private readonly string _originalLogLevel; + private readonly string _originalSampleRate; + + public EnvironmentVariableSamplingTests() + { + // Store original environment variables + _originalLogLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + _originalSampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + // Reset logger before each test + Logger.Reset(); + } + + public void Dispose() + { + // Restore original environment variables + if (_originalLogLevel != null) + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", _originalLogLevel); + else + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", null); + + if (_originalSampleRate != null) + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", _originalSampleRate); + else + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", null); + + Logger.Reset(); + } + + /// + /// Creates a logger factory that properly processes environment variables + /// + private ILoggerFactory CreateLoggerFactoryWithEnvironmentVariables(TestLoggerOutput output) + { + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.AddPowertoolsLogger(config => + { + config.Service = "HelloWorldService"; + config.LoggerOutputCase = LoggerOutputCase.CamelCase; + config.LogEvent = true; + config.LogOutput = output; + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + return serviceProvider.GetRequiredService(); + } + + /// + /// Test the exact scenario described in the GitHub issue: + /// POWERTOOLS_LOG_LEVEL=Error and POWERTOOLS_LOGGER_SAMPLE_RATE=0.9 + /// Information logs should be elevated to debug and logged when sampling is triggered + /// + [Fact] + public void EnvironmentVariables_ErrorLevelWithSampling_ShouldLogInfoWhenSamplingTriggered() + { + // Arrange + var originalLogLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + var originalSampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + try + { + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", "Error"); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", "0.9"); + + var output = new TestLoggerOutput(); + bool samplingTriggered = false; + string logOutput = ""; + + // Try multiple times to trigger sampling (90% chance each time) + for (int attempt = 0; attempt < 20 && !samplingTriggered; attempt++) + { + output.Clear(); + Logger.Reset(); + Logger.Configure(options => { options.LogOutput = output; }); + + Logger.LogError("This is an error message"); + Logger.LogInformation("Another info message"); + + logOutput = output.ToString(); + samplingTriggered = logOutput.Contains("Another info message"); + } + + // Assert + Assert.True(samplingTriggered, + $"Sampling should have been triggered within 20 attempts with 90% rate. " + + $"Last output: {logOutput}"); + + // Only verify the content if sampling was triggered + if (samplingTriggered) + { + Assert.Contains("This is an error message", logOutput); + Assert.Contains("Another info message", logOutput); + } + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", originalLogLevel); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", originalSampleRate); + Logger.Reset(); + } + } + + + /// + /// Test with POWERTOOLS_LOGGER_SAMPLE_RATE=1.0 (100% sampling) + /// This should always trigger sampling - guarantees the fix works + /// + [Fact] + public void EnvironmentVariables_ErrorLevelWithFullSampling_ShouldAlwaysLogInfo() + { + // Arrange + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", "Error"); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", "1.0"); + + var output = new TestLoggerOutput(); + + using var loggerFactory = CreateLoggerFactoryWithEnvironmentVariables(output); + var logger = loggerFactory.CreateLogger(); + + // Act + logger.LogError("This is an error message"); + logger.LogInformation("This is an info message — should appear with 100% sampling"); + + // Assert + var logOutput = output.ToString(); + Assert.Contains("Changed log level to DEBUG based on Sampling configuration", logOutput); + Assert.Contains("This is an error message", logOutput); + Assert.Contains("This is an info message — should appear with 100% sampling", logOutput); + } + + /// + /// Test with POWERTOOLS_LOGGER_SAMPLE_RATE=0 (no sampling) + /// Info messages should not be logged - ensures sampling is required + /// + [Fact] + public void EnvironmentVariables_ErrorLevelWithNoSampling_ShouldNotLogInfo() + { + // Arrange + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", "Error"); + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", "0"); + + var output = new TestLoggerOutput(); + + using var loggerFactory = CreateLoggerFactoryWithEnvironmentVariables(output); + var logger = loggerFactory.CreateLogger(); + + // Act + logger.LogError("This is an error message"); + logger.LogInformation("This is an info message — should NOT appear with 0% sampling"); + + // Assert + var logOutput = output.ToString(); + Assert.DoesNotContain("Changed log level to DEBUG based on Sampling configuration", logOutput); + Assert.Contains("This is an error message", logOutput); + Assert.DoesNotContain("This is an info message — should NOT appear with 0% sampling", logOutput); + } + + /// + /// Test the ShouldEnableDebugSampling() method without out parameter + /// + [Fact] + public void ShouldEnableDebugSampling_WithoutOutParameter_ShouldReturnCorrectValue() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0 // 100% sampling + }; + + // Act + var result = config.ShouldEnableDebugSampling(); + + // Assert + Assert.True(result); + } + + /// + /// Test the ShouldEnableDebugSampling() method with zero sampling rate + /// + [Fact] + public void ShouldEnableDebugSampling_WithZeroSamplingRate_ShouldReturnFalse() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 0.0 // 0% sampling + }; + + // Act + var result = config.ShouldEnableDebugSampling(); + + // Assert + Assert.False(result); + } + + /// + /// Test the RefreshSampleRateCalculation() method without out parameter + /// + [Fact] + public void RefreshSampleRateCalculation_WithoutOutParameter_ShouldReturnCorrectValue() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0, // 100% sampling + InitialLogLevel = LogLevel.Error, + MinimumLogLevel = LogLevel.Error + }; + + // Act - First call should return false due to cold start protection + var firstResult = config.RefreshSampleRateCalculation(); + + // Second call should return true with 100% sampling + var secondResult = config.RefreshSampleRateCalculation(); + + // Assert + Assert.False(firstResult); // Cold start protection + Assert.True(secondResult); // Should enable sampling + } + + /// + /// Test the RefreshSampleRateCalculation() method with zero sampling rate + /// + [Fact] + public void RefreshSampleRateCalculation_WithZeroSamplingRate_ShouldReturnFalse() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 0.0 // 0% sampling + }; + + // Act + var result = config.RefreshSampleRateCalculation(); + + // Assert + Assert.False(result); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Sampling/SamplingTests.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Sampling/SamplingTests.cs new file mode 100644 index 000000000..671fab4cb --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Sampling/SamplingTests.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AWS.Lambda.Powertools.Common.Tests; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace AWS.Lambda.Powertools.Logging.Tests.Sampling; + +public class SamplingTests : IDisposable +{ + private readonly string _originalLogLevel; + private readonly string _originalSampleRate; + + public SamplingTests() + { + // Store original environment variables + _originalLogLevel = Environment.GetEnvironmentVariable("POWERTOOLS_LOG_LEVEL"); + _originalSampleRate = Environment.GetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE"); + + // Reset logger before each test + Logger.Reset(); + } + + public void Dispose() + { + // Restore original environment variables + if (_originalLogLevel != null) + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", _originalLogLevel); + else + Environment.SetEnvironmentVariable("POWERTOOLS_LOG_LEVEL", null); + + if (_originalSampleRate != null) + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", _originalSampleRate); + else + Environment.SetEnvironmentVariable("POWERTOOLS_LOGGER_SAMPLE_RATE", null); + + Logger.Reset(); + } + + [Fact] + public void SamplingRate_WhenConfigured_ShouldEnableDebugSampling() + { + // Arrange + var output = new TestLoggerOutput(); + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0, // 100% sampling rate + LogOutput = output + }; + + // Act + var result = config.ShouldEnableDebugSampling(); + + // Assert + Assert.True(result); + } + + [Fact] + public void SamplingRate_WhenZero_ShouldNotEnableDebugSampling() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 0.0 // 0% sampling rate + }; + + // Act + var result = config.ShouldEnableDebugSampling(); + + // Assert + Assert.False(result); + } + + [Fact] + public void RefreshSampleRateCalculation_FirstCall_ShouldReturnFalseDueToColdStartProtection() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0, // 100% sampling rate + InitialLogLevel = LogLevel.Error, + MinimumLogLevel = LogLevel.Error + }; + + // Act + var result = config.RefreshSampleRateCalculation(); + + // Assert + Assert.False(result); // Cold start protection should prevent sampling on first call + } + + [Fact] + public void RefreshSampleRateCalculation_SecondCall_WithFullSampling_ShouldReturnTrue() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0, // 100% sampling rate + InitialLogLevel = LogLevel.Error, + MinimumLogLevel = LogLevel.Error + }; + + // Act + var firstResult = config.RefreshSampleRateCalculation(); // Cold start protection + var secondResult = config.RefreshSampleRateCalculation(); // Should enable sampling + + // Assert + Assert.False(firstResult); // Cold start protection + Assert.True(secondResult); // Should enable sampling + Assert.Equal(LogLevel.Debug, config.MinimumLogLevel); // Should have changed to Debug + } + + [Fact] + public void RefreshSampleRateCalculation_WithZeroSampling_ShouldNeverEnableSampling() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 0.0, // 0% sampling rate + InitialLogLevel = LogLevel.Error, + MinimumLogLevel = LogLevel.Error + }; + + // Act + var firstResult = config.RefreshSampleRateCalculation(); + var secondResult = config.RefreshSampleRateCalculation(); + + // Assert + Assert.False(firstResult); + Assert.False(secondResult); + Assert.Equal(LogLevel.Error, config.MinimumLogLevel); // Should remain unchanged + } + + [Fact] + public void Logger_WithSamplingEnabled_ShouldLogDebugWhenSamplingTriggered() + { + // Arrange + var output = new TestLoggerOutput(); + Logger.Configure(options => + { + options.Service = "TestService"; + options.SamplingRate = 1.0; // 100% sampling + options.MinimumLogLevel = LogLevel.Error; + options.LogOutput = output; + }); + + // Act + Logger.LogError("This is an error"); // Trigger first call (cold start protection) + Logger.LogInformation("This should be logged due to sampling"); // Should trigger sampling + + // Assert + var logOutput = output.ToString(); + Assert.Contains("This is an error", logOutput); + Assert.Contains("This should be logged due to sampling", logOutput); + Assert.Contains("Changed log level to DEBUG based on Sampling configuration", logOutput); + } + + [Fact] + public void Logger_WithNoSampling_ShouldNotLogDebugMessages() + { + // Arrange + var output = new TestLoggerOutput(); + Logger.Configure(options => + { + options.Service = "TestService"; + options.SamplingRate = 0.0; // 0% sampling + options.MinimumLogLevel = LogLevel.Error; + options.LogOutput = output; + }); + + // Act + Logger.LogError("This is an error"); + Logger.LogInformation("This should NOT be logged"); + + // Assert + var logOutput = output.ToString(); + Assert.Contains("This is an error", logOutput); + Assert.DoesNotContain("This should NOT be logged", logOutput); + Assert.DoesNotContain("Changed log level to DEBUG based on Sampling configuration", logOutput); + } + + [Fact] + public void ShouldEnableDebugSampling_WithOutParameter_ShouldReturnSamplerValue() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0 // 100% sampling + }; + + // Act + var result = config.ShouldEnableDebugSampling(out double samplerValue); + + // Assert + Assert.True(result); + Assert.True(samplerValue >= 0.0 && samplerValue <= 1.0); + Assert.True(samplerValue <= config.SamplingRate); + } + + [Fact] + public void RefreshSampleRateCalculation_WithOutParameter_ShouldProvideSamplerValue() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0, // 100% sampling + InitialLogLevel = LogLevel.Error, + MinimumLogLevel = LogLevel.Error + }; + + // Act + var firstResult = config.RefreshSampleRateCalculation(out double firstSamplerValue); + var secondResult = config.RefreshSampleRateCalculation(out double secondSamplerValue); + + // Assert + Assert.False(firstResult); // Cold start protection + Assert.Equal(0.0, firstSamplerValue); // Should be 0 during cold start protection + + Assert.True(secondResult); // Should enable sampling + Assert.True(secondSamplerValue >= 0.0 && secondSamplerValue <= 1.0); + } + + [Fact] + public void GetSafeRandom_ShouldReturnValueBetweenZeroAndOne() + { + // Act + var randomValue = PowertoolsLoggerConfiguration.GetSafeRandom(); + + // Assert + Assert.True(randomValue >= 0.0); + Assert.True(randomValue <= 1.0); + } + + [Fact] + public void SamplingRefreshCount_ShouldIncrementCorrectly() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 1.0 + }; + + // Act & Assert + Assert.Equal(0, config.SamplingRefreshCount); + + config.RefreshSampleRateCalculation(); + Assert.Equal(1, config.SamplingRefreshCount); + + config.RefreshSampleRateCalculation(); + Assert.Equal(2, config.SamplingRefreshCount); + } + + [Fact] + public void RefreshSampleRateCalculation_ShouldEnableDebugLogging() + { + // Arrange + var output = new TestLoggerOutput(); + Logger.Configure(options => + { + options.Service = "TestService"; + options.SamplingRate = 1.0; // 100% sampling + options.MinimumLogLevel = LogLevel.Error; + options.LogOutput = output; + }); + + // Act - First refresh (cold start protection) + Logger.RefreshSampleRateCalculation(); + Logger.LogDebug("This should not appear"); + + // Clear output from first attempt + output.Clear(); + + // Second refresh (should enable sampling) + Logger.RefreshSampleRateCalculation(); + Logger.LogDebug("This should appear after sampling"); + + // Assert + var logOutput = output.ToString(); + Assert.Contains("This should appear after sampling", logOutput); + Assert.Contains("Changed log level to DEBUG based on Sampling configuration", logOutput); + } + + [Fact] + public void Logger_RefreshSampleRateCalculation_ShouldTriggerConfigurationUpdate() + { + // Arrange + var output = new TestLoggerOutput(); + Logger.Configure(options => + { + options.Service = "TestService"; + options.SamplingRate = 1.0; // 100% sampling + options.MinimumLogLevel = LogLevel.Warning; + options.LogOutput = output; + }); + + // Verify initial state - debug logs should not appear (sampling not yet triggered) + Logger.LogDebug("Initial debug - should not appear"); + Assert.DoesNotContain("Initial debug", output.ToString()); + + output.Clear(); + + // Act - Trigger sampling refresh + // First call is protected by cold start logic, second call should enable sampling + Logger.RefreshSampleRateCalculation(); // Cold start protection - no effect + var samplingEnabled = Logger.RefreshSampleRateCalculation(); // Should enable debug sampling + + // Verify sampling was enabled + Assert.True(samplingEnabled, "Sampling should be enabled with 100% rate"); + + // Now debug logs should appear because sampling elevated the log level + Logger.LogDebug("Debug after sampling - should appear"); + + // Assert + var logOutput = output.ToString(); + Assert.Contains("Debug after sampling - should appear", logOutput); + Assert.Contains("Changed log level to DEBUG based on Sampling configuration", logOutput); + } + + + + + + [Fact] + public void RefreshSampleRateCalculation_ShouldGenerateRandomValues_OverMultipleIterations() + { + // Arrange + var config = new PowertoolsLoggerConfiguration + { + SamplingRate = 0.5, // 50% sampling rate + MinimumLogLevel = LogLevel.Error, + InitialLogLevel = LogLevel.Error + }; + + var samplerValues = new List(); + bool samplingTriggeredAtLeastOnce = false; + bool samplingNotTriggeredAtLeastOnce = false; + + // Act - Try up to 20 times to verify random behavior + for (int i = 0; i < 20; i++) + { + // Reset for each iteration + config.SamplingRefreshCount = 1; // Skip cold start protection + + bool wasTriggered = config.RefreshSampleRateCalculation(out double samplerValue); + samplerValues.Add(samplerValue); + + if (wasTriggered) + { + samplingTriggeredAtLeastOnce = true; + } + else + { + samplingNotTriggeredAtLeastOnce = true; + } + } + + // Assert + // Verify that we got different random values (not all the same) + var uniqueValues = samplerValues.Distinct().Count(); + Assert.True(uniqueValues > 1, "Should generate different random values across iterations"); + + // With 50% sampling rate over 20 iterations, we should see both triggered and not triggered cases + // (probability of all same outcome is extremely low: 0.5^20 ≈ 0.000001) + Assert.True(samplingTriggeredAtLeastOnce, + "Sampling should have been triggered at least once in 20 iterations with 50% rate"); + Assert.True(samplingNotTriggeredAtLeastOnce, + "Sampling should have been skipped at least once in 20 iterations with 50% rate"); + + // Verify all sampler values are within valid range [0, 1] + Assert.True((bool)samplerValues.All(v => v >= 0.0 && v <= 1.0), "All sampler values should be between 0 and 1"); + } +} \ No newline at end of file