Skip to content

Commit efafc90

Browse files
feat(logger): add new logic to sample debug logs (#6142)
* Adding new logic to sampling request * Adding new logic to sampling request * Adding new logic to sampling request * Adding docs and examples * Addressing Andrea's feedback * Addressing Andrea's feedback * Addressing Andrea's feedback
1 parent 958651c commit efafc90

File tree

5 files changed

+181
-15
lines changed

5 files changed

+181
-15
lines changed

aws_lambda_powertools/logging/logger.py

+29-3
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ def __init__(
220220
serialize_stacktrace: bool = True,
221221
**kwargs,
222222
) -> None:
223+
224+
# Used in case of sampling
225+
self.initial_log_level = self._determine_log_level(level)
226+
223227
self.service = resolve_env_var_choice(
224228
choice=service,
225229
env=os.getenv(constants.SERVICE_NAME_ENV, "service_undefined"),
@@ -339,6 +343,17 @@ def _init_logger(
339343
self._logger.init = True # type: ignore[attr-defined]
340344
self._logger.powertools_handler = self.logger_handler # type: ignore[attr-defined]
341345

346+
def refresh_sample_rate_calculation(self) -> None:
347+
"""
348+
Refreshes the sample rate calculation by reconfiguring logging settings.
349+
350+
Returns
351+
-------
352+
None
353+
"""
354+
self._logger.setLevel(self.initial_log_level)
355+
self._configure_sampling()
356+
342357
def _configure_sampling(self) -> None:
343358
"""Dynamically set log level based on sampling rate
344359
@@ -347,15 +362,20 @@ def _configure_sampling(self) -> None:
347362
InvalidLoggerSamplingRateError
348363
When sampling rate provided is not a float
349364
"""
365+
if not self.sampling_rate:
366+
return
367+
350368
try:
351-
if self.sampling_rate and random.random() <= float(self.sampling_rate):
352-
logger.debug("Setting log level to Debug due to sampling rate")
369+
# This is not testing < 0 or > 1 conditions
370+
# Because I don't need other if condition here
371+
if random.random() <= float(self.sampling_rate):
353372
self._logger.setLevel(logging.DEBUG)
373+
logger.debug("Setting log level to DEBUG due to sampling rate")
354374
except ValueError:
355375
raise InvalidLoggerSamplingRateError(
356376
(
357377
f"Expected a float value ranging 0 to 1, but received {self.sampling_rate} instead."
358-
"Please review POWERTOOLS_LOGGER_SAMPLE_RATE environment variable."
378+
"Please review POWERTOOLS_LOGGER_SAMPLE_RATE environment variable or `sampling_rate` parameter."
359379
),
360380
)
361381

@@ -465,6 +485,12 @@ def decorate(event, context, *args, **kwargs):
465485
logger.debug("Event received")
466486
self.info(extract_event_from_common_models(event))
467487

488+
# Sampling rate is defined, and this is not ColdStart
489+
# then we need to recalculate the sampling
490+
# See: https://github.com/aws-powertools/powertools-lambda-python/issues/6141
491+
if self.sampling_rate and not cold_start:
492+
self.refresh_sample_rate_calculation()
493+
468494
return lambda_handler(event, context, *args, **kwargs)
469495

470496
return decorate

docs/core/logger.md

+13-10
Original file line numberDiff line numberDiff line change
@@ -627,24 +627,27 @@ Notice in the CloudWatch Logs output how `payment_id` appears as expected when l
627627

628628
### Sampling debug logs
629629

630-
Use sampling when you want to dynamically change your log level to **DEBUG** based on a **percentage of your concurrent/cold start invocations**.
630+
Use sampling when you want to dynamically change your log level to **DEBUG** based on a **percentage of the Lambda function invocations**.
631631

632-
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.
632+
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.
633633

634634
???+ tip "Tip: When is this useful?"
635-
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.
635+
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.
636636

637-
This feature takes into account transient issues where additional debugging information can be useful.
637+
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.
638638

639-
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.
639+
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.
640640

641-
???+ note
642-
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
641+
=== "sampling_debug_logs_with_decorator.py"
643642

644-
=== "sampling_debug_logs.py"
643+
```python hl_lines="5 8"
644+
--8<-- "examples/logger/src/sampling_debug_logs_with_decorator.py"
645+
```
645646

646-
```python hl_lines="6 10"
647-
--8<-- "examples/logger/src/sampling_debug_logs.py"
647+
=== "sampling_debug_logs_with_standalone_function.py"
648+
649+
```python hl_lines="5 12"
650+
--8<-- "examples/logger/src/sampling_debug_logs_with_standalone_function.py"
648651
```
649652

650653
=== "sampling_debug_logs_output.json"

examples/logger/src/sampling_debug_logs.py renamed to examples/logger/src/sampling_debug_logs_with_decorator.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
from aws_lambda_powertools.utilities.typing import LambdaContext
33

44
# Sample 10% of debug logs e.g. 0.1
5-
# NOTE: this evaluation will only occur at cold start
65
logger = Logger(service="payment", sample_rate=0.1)
76

87

8+
@logger.inject_lambda_context
99
def lambda_handler(event: dict, context: LambdaContext):
1010
logger.debug("Verifying whether order_id is present")
1111
logger.info("Collecting payment")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from aws_lambda_powertools import Logger
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
# Sample 10% of debug logs e.g. 0.1
5+
logger = Logger(service="payment", sample_rate=0.1)
6+
7+
8+
def lambda_handler(event: dict, context: LambdaContext):
9+
logger.debug("Verifying whether order_id is present")
10+
logger.info("Collecting payment")
11+
12+
logger.refresh_sample_rate_calculation()
13+
14+
return "hello world"

tests/functional/logger/required_dependencies/test_logger.py

+124-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def test_setup_service_env_var(monkeypatch, stdout, service_name):
8686
assert service_name == log["service"]
8787

8888

89-
def test_setup_sampling_rate_env_var(monkeypatch, stdout, service_name):
89+
def test_setup_sampling_rate_env_var_with_100percent(monkeypatch, stdout, service_name):
9090
# GIVEN Logger is initialized
9191
# WHEN samping rate is explicitly set to 100% via POWERTOOLS_LOGGER_SAMPLE_RATE env
9292
sampling_rate = "1"
@@ -103,6 +103,129 @@ def test_setup_sampling_rate_env_var(monkeypatch, stdout, service_name):
103103
assert "I am being sampled" == log["message"]
104104

105105

106+
def test_setup_sampling_rate_constructor_with_100percent(stdout, service_name):
107+
# GIVEN Logger is initialized
108+
# WHEN samping rate is explicitly set to 100% via constructor
109+
sampling_rate = 1
110+
logger = Logger(service=service_name, sampling_rate=sampling_rate, stream=stdout)
111+
logger.debug("I am being sampled")
112+
113+
# THEN sampling rate should be equals sampling_rate value
114+
# log level should be DEBUG
115+
# and debug log statements should be in stdout
116+
log = capture_logging_output(stdout)
117+
assert sampling_rate == log["sampling_rate"]
118+
assert "DEBUG" == log["level"]
119+
assert "I am being sampled" == log["message"]
120+
121+
122+
def test_setup_sampling_rate_env_var_with_0percent(monkeypatch, stdout, service_name):
123+
# GIVEN Logger is initialized
124+
# WHEN samping rate is explicitly set to 0% via POWERTOOLS_LOGGER_SAMPLE_RATE env
125+
sampling_rate = "0"
126+
monkeypatch.setenv("POWERTOOLS_LOGGER_SAMPLE_RATE", sampling_rate)
127+
logger = Logger(service=service_name, stream=stdout)
128+
logger.debug("I am being sampled")
129+
130+
# THEN we should not log
131+
logs = list(stdout.getvalue().strip())
132+
assert not logs
133+
134+
135+
def test_setup_sampling_rate_constructor_with_0percent(stdout, service_name):
136+
# GIVEN Logger is initialized
137+
# WHEN samping rate is explicitly set to 100% via constructor
138+
sampling_rate = 0
139+
logger = Logger(service=service_name, sampling_rate=sampling_rate, stream=stdout)
140+
logger.debug("I am being sampled")
141+
142+
# THEN we should not log
143+
logs = list(stdout.getvalue().strip())
144+
assert not logs
145+
146+
147+
@pytest.mark.parametrize(
148+
"percent, minimum_logs, maximum_logs",
149+
[
150+
(0.5, 35, 65),
151+
(0.1, 0, 20),
152+
(0.9, 75, 115),
153+
],
154+
)
155+
def test_setup_sampling_rate_env_var_with_percent_and_decorator(
156+
lambda_context,
157+
stdout,
158+
service_name,
159+
percent,
160+
minimum_logs,
161+
maximum_logs,
162+
):
163+
# GIVEN the Logger is initialized with a specific sampling rate
164+
sampling_rate = percent
165+
total_runs = 100
166+
minimum_logs_excepted = minimum_logs
167+
maximum_logs_excepted = maximum_logs
168+
logger = Logger(service=service_name, level="INFO", sampling_rate=sampling_rate, stream=stdout)
169+
170+
@logger.inject_lambda_context
171+
def handler(event, context):
172+
logger.debug("test")
173+
174+
# WHEN A lambda handler is invoked multiple times with decorator
175+
for _i in range(total_runs):
176+
handler({}, lambda_context)
177+
178+
# THEN verify the number of logs falls within the expected range
179+
logs = list(stdout.getvalue().strip().split("\n"))
180+
assert (
181+
len(logs) >= minimum_logs_excepted
182+
), f"Log count {len(logs)} should be at least {minimum_logs_excepted} for sampling rate {sampling_rate}"
183+
assert (
184+
len(logs) <= maximum_logs_excepted
185+
), f"Log count {len(logs)} should be at most {maximum_logs_excepted} for sampling rate {sampling_rate}"
186+
187+
188+
@pytest.mark.parametrize(
189+
"percent, minimum_logs, maximum_logs",
190+
[
191+
(0.5, 35, 65),
192+
(0.1, 0, 20),
193+
(0.9, 75, 115),
194+
],
195+
)
196+
def test_setup_sampling_rate_env_var_with_percent_and_recalculate_manual_method(
197+
lambda_context,
198+
stdout,
199+
service_name,
200+
percent,
201+
minimum_logs,
202+
maximum_logs,
203+
):
204+
# GIVEN the Logger is initialized with a specific sampling rate
205+
sampling_rate = percent
206+
total_runs = 100
207+
minimum_logs_excepted = minimum_logs
208+
maximum_logs_excepted = maximum_logs
209+
logger = Logger(service=service_name, level="INFO", sampling_rate=sampling_rate, stream=stdout)
210+
211+
def handler(event, context):
212+
logger.debug("test")
213+
logger.refresh_sample_rate_calculation()
214+
215+
# WHEN A lambda handler is invoked multiple times with manual refresh_sample_rate_calculation()
216+
for _i in range(total_runs):
217+
handler({}, lambda_context)
218+
219+
# THEN verify the number of logs falls within the expected range
220+
logs = list(stdout.getvalue().strip().split("\n"))
221+
assert (
222+
len(logs) >= minimum_logs_excepted
223+
), f"Log count {len(logs)} should be at least {minimum_logs_excepted} for sampling rate {sampling_rate}"
224+
assert (
225+
len(logs) <= maximum_logs_excepted
226+
), f"Log count {len(logs)} should be at most {maximum_logs_excepted} for sampling rate {sampling_rate}"
227+
228+
106229
def test_inject_lambda_context(lambda_context, stdout, service_name):
107230
# GIVEN Logger is initialized
108231
logger = Logger(service=service_name, stream=stdout)

0 commit comments

Comments
 (0)