Repository: aws-powertools/powertools-lambda-python

Issue:
Feature request: Calculate Log Sampling Per Request
### Use case

Original discussion: https://github.com/aws-powertools/powertools-lambda-typescript/issues/3278

Copying @dreamorosi's [comment](https://github.com/aws-powertools/powertools-lambda-typescript/issues/3278#issuecomment-2664890356) to add more context to this feature request:

> Had a discussion about this with @am29d yesterday and I thought it'd be useful to bring here the outcomes of the discussion, primarily for future implementation & consideration.

> The log sampling feature changes the log level of a Logger to `debug` for a percentage of requests. Customers can set a rate in the Logger constructor and this rate is used to determine whether or not to change the log level to `debug`. This is useful for customers who want to keep a less verbose log level most of the time, but have more logs emitted for a percentage of their requests.

> As it stands, the feature doesn't exactly behaves as described above. This is because the percentage or ratio is not calculated at the request level, but rather when the Logger class is instantiated, which is usually during the `INIT` phase of the execution environment, i.e.

```python
from aws_lambda_powertools import Logger

logger = Logger(sampling_rate=0.5)   # whether or not the log level is switched to `debug` is decided here only

def handler(event, context):
    # ... your logic here
    pass

```

> This means that all the requests served by the same environment will inherit the sampling decision that was made when the environment was initialized, which in turn results in a sampling rate different than the desired one. The degree of this difference will depend on how many environments are spun up and the distribution of requests among them.

> To explain what I mean by that, let's consider this example that has 3 environments/sandboxes and a number of requests distributed across them, and - for the sake of simplicity - a log sampling of 0.5 (aka 50%):

> ![Image](https://github.com/user-attachments/assets/fc21d826-065d-4379-ac7b-2ea5a13b4a99)

> Assuming a truly random chance of 50%, one could end up in a situation like the above, which would result in a sample rate of ~85% rather than the expected 50%.

> To get around this, and instead get a more closer rate to the desired 50%, customers can use the [`logger.refresh_sample_rate_calculation()`](https://docs.powertools.aws.dev/lambda/typescript/latest/api/classes/_aws-lambda-powertools_logger.index.Logger.html#refreshsampleratecalculation) method mentioned above at the start/end of each request. 

```python
from aws_lambda_powertools import Logger

logger = Logger(sampling_rate=0.5)   # whether or not the log level is switched to `debug` is decided here only

def handler(event, context):
    # ... your logic here
    logger.refresh_sample_rate_calculation()
    pass
```

> When called, this method essentially flips the coin again and decides whether the log level should be switched to `debug` or not. Because this is done at the request level, statistically speaking, the ratio of sampled requests should be much closer to the desired one:

> ![Image](https://github.com/user-attachments/assets/ed465c2f-3240-4ca7-b8e2-1dcc380f808f)

> With this in mind, we should consider easing this situation for customers by adding an optional flag to our class method decorator and Middy.js middleware so that when this flag is enabled, we'll call the `logger.refresh_sample_rate_calculation()` method for them at the end of each request, as proposed [here](https://github.com/aws-powertools/powertools-lambda-typescript/issues/3278#issuecomment-2497752384).

> The flag would be `false` by default to maintain backward compatibility, although in a future major version we could consider making it enabled by default since this would be a much accurate behavior than the current one. 

> Obviously, as mentioned above, this would work only if we're able to wrap the handler, so customers who are not using either of the two mechanisms just mentioned would have to continue calling the `logger.refresh_sample_rate_calculation()` manually.



### Solution/User Experience

We want to have this experience:

1 - Customers will continue to set `sampling_rate` at the constructor level.

2 - Customers using the `@logger.inject_lambda_context` decorator will observe the sampling rate being recalculated on every request and having the expected result.

3 - Customers not using the decorator must call `refresh_sample_rate_calculation` manually.

4 - Customers not using the decorator or the `refresh_sample_rate_calculation` method will end up with unexpected sampling rates/logs.

5 - We need to change our documentation to make it more clear.


### Alternative solutions

```markdown

```

### Acknowledgment

- [x] This feature request meets [Powertools for AWS Lambda (Python) Tenets](https://docs.powertools.aws.dev/lambda/python/latest/#tenets)
- [x] Should this be considered in other Powertools for AWS Lambda languages? i.e. [Java](https://github.com/aws-powertools/powertools-lambda-java/), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript/), and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet/)

Discussion:
@github-actions: ### ⚠️COMMENT VISIBILITY WARNING⚠️
This issue is now closed. Please be mindful that future comments are hard for our team to see.

If you need more assistance, please either tag a [team member](https://github.com/aws-powertools/powertools-lambda-python/blob/develop/MAINTAINERS.md#current-maintainers) or open a new issue that references this one.

If you wish to keep having a conversation with other community members under this issue feel free to do so.@github-actions: This is now released under [3.9.0](https://github.com/aws-powertools/powertools-lambda-python/releases/tag/v3.9.0) version!

Solution patch:
diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py
index 16ae3bace..36f4ed1f1 100644
--- a/aws_lambda_powertools/logging/logger.py
+++ b/aws_lambda_powertools/logging/logger.py
@@ -220,6 +220,10 @@ class Logger:
         serialize_stacktrace: bool = True,
         **kwargs,
     ) -> None:
+
+        # Used in case of sampling
+        self.initial_log_level = self._determine_log_level(level)
+
         self.service = resolve_env_var_choice(
             choice=service,
             env=os.getenv(constants.SERVICE_NAME_ENV, "service_undefined"),
@@ -339,6 +343,17 @@ class Logger:
         self._logger.init = True  # type: ignore[attr-defined]
         self._logger.powertools_handler = self.logger_handler  # type: ignore[attr-defined]
 
+    def refresh_sample_rate_calculation(self) -> None:
+        """
+        Refreshes the sample rate calculation by reconfiguring logging settings.
+
+        Returns
+        -------
+            None
+        """
+        self._logger.setLevel(self.initial_log_level)
+        self._configure_sampling()
+
     def _configure_sampling(self) -> None:
         """Dynamically set log level based on sampling rate
 
@@ -347,15 +362,20 @@ class Logger:
         InvalidLoggerSamplingRateError
             When sampling rate provided is not a float
         """
+        if not self.sampling_rate:
+            return
+
         try:
-            if self.sampling_rate and random.random() <= float(self.sampling_rate):
-                logger.debug("Setting log level to Debug due to sampling rate")
+            # This is not testing < 0 or > 1 conditions
+            # Because I don't need other if condition here
+            if random.random() <= float(self.sampling_rate):
                 self._logger.setLevel(logging.DEBUG)
+                logger.debug("Setting log level to DEBUG due to sampling rate")
         except ValueError:
             raise InvalidLoggerSamplingRateError(
                 (
                     f"Expected a float value ranging 0 to 1, but received {self.sampling_rate} instead."
-                    "Please review POWERTOOLS_LOGGER_SAMPLE_RATE environment variable."
+                    "Please review POWERTOOLS_LOGGER_SAMPLE_RATE environment variable or `sampling_rate` parameter."
                 ),
             )
 
@@ -465,6 +485,12 @@ class Logger:
                 logger.debug("Event received")
                 self.info(extract_event_from_common_models(event))
 
+            # Sampling rate is defined, and this is not ColdStart
+            # then we need to recalculate the sampling
+            # See: https://github.com/aws-powertools/powertools-lambda-python/issues/6141
+            if self.sampling_rate and not cold_start:
+                self.refresh_sample_rate_calculation()
+
             return lambda_handler(event, context, *args, **kwargs)
 
         return decorate
diff --git a/docs/core/logger.md b/docs/core/logger.md
index e60585557..3e19d8ebc 100644
--- a/docs/core/logger.md
+++ b/docs/core/logger.md
@@ -627,24 +627,27 @@ Notice in the CloudWatch Logs output how `payment_id` appears as expected when l
 
 ### Sampling debug logs
 
-Use sampling when you want to dynamically change your log level to **DEBUG** based on a **percentage of your concurrent/cold start invocations**.
+Use sampling when you want to dynamically change your log level to **DEBUG** based on a **percentage of the Lambda function invocations**.
 
-You can use values ranging from `0.0` to `1` (100%) when setting `POWERTOOLS_LOGGER_SAMPLE_RATE` env var, or `sample_rate` parameter in Logger.
+You can use values ranging from `0.0` to `1` (100%) when setting `POWERTOOLS_LOGGER_SAMPLE_RATE` env var, or `sampling_rate` parameter in Logger.
 
 ???+ tip "Tip: When is this useful?"
-    Let's imagine a sudden spike increase in concurrency triggered a transient issue downstream. When looking into the logs you might not have enough information, and while you can adjust log levels it might not happen again.
+    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.
 
-    This feature takes into account transient issues where additional debugging information can be useful.
+    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.
 
-Sampling decision happens at the Logger initialization. This means sampling may happen significantly more or less than depending on your traffic patterns, for example a steady low number of invocations and thus few cold starts.
+The sampling decision happens automatically with each invocation when using `@logger.inject_lambda_context` decorator.  When not using the decorator, you're in charge of refreshing it via `refresh_sample_rate_calculation` method. Skipping both may lead to unexpected sampling results.
 
-???+ note
-	Open a [feature request](https://github.com/aws-powertools/powertools-lambda-python/issues/new?assignees=&labels=feature-request%2C+triage&template=feature_request.md&title=){target="_blank"} if you want Logger to calculate sampling for every invocation
+=== "sampling_debug_logs_with_decorator.py"
 
-=== "sampling_debug_logs.py"
+    ```python hl_lines="5 8"
+    --8<-- "examples/logger/src/sampling_debug_logs_with_decorator.py"
+    ```
 
-    ```python hl_lines="6 10"
-    --8<-- "examples/logger/src/sampling_debug_logs.py"
+=== "sampling_debug_logs_with_standalone_function.py"
+
+    ```python hl_lines="5 12"
+    --8<-- "examples/logger/src/sampling_debug_logs_with_standalone_function.py"
     ```
 
 === "sampling_debug_logs_output.json"
diff --git a/examples/logger/src/sampling_debug_logs.py b/examples/logger/src/sampling_debug_logs_with_decorator.py
similarity index 87%
rename from examples/logger/src/sampling_debug_logs.py
rename to examples/logger/src/sampling_debug_logs_with_decorator.py
index 042c1f4a5..b4b7c5942 100644
--- a/examples/logger/src/sampling_debug_logs.py
+++ b/examples/logger/src/sampling_debug_logs_with_decorator.py
@@ -2,10 +2,10 @@ from aws_lambda_powertools import Logger
 from aws_lambda_powertools.utilities.typing import LambdaContext
 
 # Sample 10% of debug logs e.g. 0.1
-# NOTE: this evaluation will only occur at cold start
 logger = Logger(service="payment", sample_rate=0.1)
 
 
+@logger.inject_lambda_context
 def lambda_handler(event: dict, context: LambdaContext):
     logger.debug("Verifying whether order_id is present")
     logger.info("Collecting payment")
diff --git a/examples/logger/src/sampling_debug_logs_with_standalone_function.py b/examples/logger/src/sampling_debug_logs_with_standalone_function.py
new file mode 100644
index 000000000..8d68eb183
--- /dev/null
+++ b/examples/logger/src/sampling_debug_logs_with_standalone_function.py
@@ -0,0 +1,14 @@
+from aws_lambda_powertools import Logger
+from aws_lambda_powertools.utilities.typing import LambdaContext
+
+# Sample 10% of debug logs e.g. 0.1
+logger = Logger(service="payment", sample_rate=0.1)
+
+
+def lambda_handler(event: dict, context: LambdaContext):
+    logger.debug("Verifying whether order_id is present")
+    logger.info("Collecting payment")
+
+    logger.refresh_sample_rate_calculation()
+
+    return "hello world"


Test patch:
diff --git a/tests/functional/logger/required_dependencies/test_logger.py b/tests/functional/logger/required_dependencies/test_logger.py
index f24866ea8..71dfbc276 100644
--- a/tests/functional/logger/required_dependencies/test_logger.py
+++ b/tests/functional/logger/required_dependencies/test_logger.py
@@ -86,7 +86,7 @@ def test_setup_service_env_var(monkeypatch, stdout, service_name):
     assert service_name == log["service"]
 
 
-def test_setup_sampling_rate_env_var(monkeypatch, stdout, service_name):
+def test_setup_sampling_rate_env_var_with_100percent(monkeypatch, stdout, service_name):
     # GIVEN Logger is initialized
     # WHEN samping rate is explicitly set to 100% via POWERTOOLS_LOGGER_SAMPLE_RATE env
     sampling_rate = "1"
@@ -103,6 +103,129 @@ def test_setup_sampling_rate_env_var(monkeypatch, stdout, service_name):
     assert "I am being sampled" == log["message"]
 
 
+def test_setup_sampling_rate_constructor_with_100percent(stdout, service_name):
+    # GIVEN Logger is initialized
+    # WHEN samping rate is explicitly set to 100% via constructor
+    sampling_rate = 1
+    logger = Logger(service=service_name, sampling_rate=sampling_rate, stream=stdout)
+    logger.debug("I am being sampled")
+
+    # THEN sampling rate should be equals sampling_rate value
+    # log level should be DEBUG
+    # and debug log statements should be in stdout
+    log = capture_logging_output(stdout)
+    assert sampling_rate == log["sampling_rate"]
+    assert "DEBUG" == log["level"]
+    assert "I am being sampled" == log["message"]
+
+
+def test_setup_sampling_rate_env_var_with_0percent(monkeypatch, stdout, service_name):
+    # GIVEN Logger is initialized
+    # WHEN samping rate is explicitly set to 0% via POWERTOOLS_LOGGER_SAMPLE_RATE env
+    sampling_rate = "0"
+    monkeypatch.setenv("POWERTOOLS_LOGGER_SAMPLE_RATE", sampling_rate)
+    logger = Logger(service=service_name, stream=stdout)
+    logger.debug("I am being sampled")
+
+    # THEN we should not log
+    logs = list(stdout.getvalue().strip())
+    assert not logs
+
+
+def test_setup_sampling_rate_constructor_with_0percent(stdout, service_name):
+    # GIVEN Logger is initialized
+    # WHEN samping rate is explicitly set to 100% via constructor
+    sampling_rate = 0
+    logger = Logger(service=service_name, sampling_rate=sampling_rate, stream=stdout)
+    logger.debug("I am being sampled")
+
+    # THEN we should not log
+    logs = list(stdout.getvalue().strip())
+    assert not logs
+
+
+@pytest.mark.parametrize(
+    "percent, minimum_logs, maximum_logs",
+    [
+        (0.5, 35, 65),
+        (0.1, 0, 20),
+        (0.9, 75, 115),
+    ],
+)
+def test_setup_sampling_rate_env_var_with_percent_and_decorator(
+    lambda_context,
+    stdout,
+    service_name,
+    percent,
+    minimum_logs,
+    maximum_logs,
+):
+    # GIVEN the Logger is initialized with a specific sampling rate
+    sampling_rate = percent
+    total_runs = 100
+    minimum_logs_excepted = minimum_logs
+    maximum_logs_excepted = maximum_logs
+    logger = Logger(service=service_name, level="INFO", sampling_rate=sampling_rate, stream=stdout)
+
+    @logger.inject_lambda_context
+    def handler(event, context):
+        logger.debug("test")
+
+    # WHEN A lambda handler is invoked multiple times with decorator
+    for _i in range(total_runs):
+        handler({}, lambda_context)
+
+    # THEN verify the number of logs falls within the expected range
+    logs = list(stdout.getvalue().strip().split("\n"))
+    assert (
+        len(logs) >= minimum_logs_excepted
+    ), f"Log count {len(logs)} should be at least {minimum_logs_excepted} for sampling rate {sampling_rate}"
+    assert (
+        len(logs) <= maximum_logs_excepted
+    ), f"Log count {len(logs)} should be at most {maximum_logs_excepted} for sampling rate {sampling_rate}"
+
+
+@pytest.mark.parametrize(
+    "percent, minimum_logs, maximum_logs",
+    [
+        (0.5, 35, 65),
+        (0.1, 0, 20),
+        (0.9, 75, 115),
+    ],
+)
+def test_setup_sampling_rate_env_var_with_percent_and_recalculate_manual_method(
+    lambda_context,
+    stdout,
+    service_name,
+    percent,
+    minimum_logs,
+    maximum_logs,
+):
+    # GIVEN the Logger is initialized with a specific sampling rate
+    sampling_rate = percent
+    total_runs = 100
+    minimum_logs_excepted = minimum_logs
+    maximum_logs_excepted = maximum_logs
+    logger = Logger(service=service_name, level="INFO", sampling_rate=sampling_rate, stream=stdout)
+
+    def handler(event, context):
+        logger.debug("test")
+        logger.refresh_sample_rate_calculation()
+
+    # WHEN A lambda handler is invoked multiple times with manual refresh_sample_rate_calculation()
+    for _i in range(total_runs):
+        handler({}, lambda_context)
+
+    # THEN verify the number of logs falls within the expected range
+    logs = list(stdout.getvalue().strip().split("\n"))
+    assert (
+        len(logs) >= minimum_logs_excepted
+    ), f"Log count {len(logs)} should be at least {minimum_logs_excepted} for sampling rate {sampling_rate}"
+    assert (
+        len(logs) <= maximum_logs_excepted
+    ), f"Log count {len(logs)} should be at most {maximum_logs_excepted} for sampling rate {sampling_rate}"
+
+
 def test_inject_lambda_context(lambda_context, stdout, service_name):
     # GIVEN Logger is initialized
     logger = Logger(service=service_name, stream=stdout)