Skip to content

Commit 64bbe9f

Browse files
authored
Merge branch 'develop' into nested_event_sources
2 parents ef35f02 + 69d25ef commit 64bbe9f

File tree

9 files changed

+172
-81
lines changed

9 files changed

+172
-81
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,4 @@ examples/**/sam/.aws-sam
314314

315315
cdk.out
316316
# NOTE: different accounts will be used for E2E thus creating unnecessary git clutter
317-
cdk.context.json
317+
cdk.context.json

.markdownlint.yaml

-4
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,6 @@ MD013:
7373
tables: false
7474
# Include headings
7575
headings: true
76-
# Include headings
77-
headers: true
7876
# Strict length checking
7977
strict: false
8078
# Stern length checking
@@ -107,8 +105,6 @@ MD023: true
107105

108106
# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content
109107
MD024:
110-
# Only check sibling headings
111-
allow_different_nesting: false
112108
# Only check sibling headings
113109
siblings_only: false
114110

CHANGELOG.md

+39-33
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# logger.powertools_handler is set with Powertools Logger handler; useful when there are many handlers
2+
LOGGER_ATTRIBUTE_POWERTOOLS_HANDLER = "powertools_handler"
3+
# logger.init attribute is set when Logger has been configured
4+
LOGGER_ATTRIBUTE_PRECONFIGURED = "init"
5+
LOGGER_ATTRIBUTE_HANDLER = "logger_handler"
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
class InvalidLoggerSamplingRateError(Exception):
22
pass
3+
4+
5+
class OrphanedChildLoggerError(Exception):
6+
pass

aws_lambda_powertools/logging/logger.py

+34-10
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@
1919
Optional,
2020
TypeVar,
2121
Union,
22+
cast,
2223
overload,
2324
)
2425

26+
from aws_lambda_powertools.logging.constants import (
27+
LOGGER_ATTRIBUTE_HANDLER,
28+
LOGGER_ATTRIBUTE_POWERTOOLS_HANDLER,
29+
LOGGER_ATTRIBUTE_PRECONFIGURED,
30+
)
2531
from aws_lambda_powertools.shared import constants
2632
from aws_lambda_powertools.shared.functions import (
2733
extract_event_from_common_models,
@@ -31,7 +37,7 @@
3137
from aws_lambda_powertools.utilities import jmespath_utils
3238

3339
from ..shared.types import AnyCallableT
34-
from .exceptions import InvalidLoggerSamplingRateError
40+
from .exceptions import InvalidLoggerSamplingRateError, OrphanedChildLoggerError
3541
from .filters import SuppressFilter
3642
from .formatter import (
3743
RESERVED_FORMATTER_CUSTOM_KEYS,
@@ -233,14 +239,14 @@ def __init__(
233239
self.child = child
234240
self.logger_formatter = logger_formatter
235241
self._stream = stream or sys.stdout
236-
self.logger_handler = logger_handler or logging.StreamHandler(self._stream)
237242
self.log_uncaught_exceptions = log_uncaught_exceptions
238243

239244
self._is_deduplication_disabled = resolve_truthy_env_var_choice(
240245
env=os.getenv(constants.LOGGER_LOG_DEDUPLICATION_ENV, "false"),
241246
)
242247
self._default_log_keys = {"service": self.service, "sampling_rate": self.sampling_rate}
243248
self._logger = self._get_logger()
249+
self.logger_handler = logger_handler or self._get_handler()
244250

245251
# NOTE: This is primarily to improve UX, so IDEs can autocomplete LambdaPowertoolsFormatter options
246252
# previously, we masked all of them as kwargs thus limiting feature discovery
@@ -264,7 +270,7 @@ def __init__(
264270

265271
# Prevent __getattr__ from shielding unknown attribute errors in type checkers
266272
# https://github.com/aws-powertools/powertools-lambda-python/issues/1660
267-
if not TYPE_CHECKING:
273+
if not TYPE_CHECKING: # pragma: no cover
268274

269275
def __getattr__(self, name):
270276
# Proxy attributes not found to actual logger to support backward compatibility
@@ -279,6 +285,18 @@ def _get_logger(self) -> logging.Logger:
279285

280286
return logging.getLogger(logger_name)
281287

288+
def _get_handler(self) -> logging.Handler:
289+
# is a logger handler already configured?
290+
if getattr(self, LOGGER_ATTRIBUTE_HANDLER, None):
291+
return self.logger_handler
292+
293+
# for children, use parent's handler
294+
if self.child:
295+
return getattr(self._logger.parent, LOGGER_ATTRIBUTE_POWERTOOLS_HANDLER, None) # type: ignore[return-value] # always checked in formatting
296+
297+
# otherwise, create a new stream handler (first time init)
298+
return logging.StreamHandler(self._stream)
299+
282300
def _init_logger(
283301
self,
284302
formatter_options: Optional[Dict] = None,
@@ -292,7 +310,7 @@ def _init_logger(
292310
# a) multiple handlers being attached
293311
# b) different sampling mechanisms
294312
# c) multiple messages from being logged as handlers can be duplicated
295-
is_logger_preconfigured = getattr(self._logger, "init", False)
313+
is_logger_preconfigured = getattr(self._logger, LOGGER_ATTRIBUTE_PRECONFIGURED, False)
296314
if self.child or is_logger_preconfigured:
297315
return
298316

@@ -317,6 +335,7 @@ def _init_logger(
317335
# std logging will return the same Logger with our attribute if name is reused
318336
logger.debug(f"Marking logger {self.service} as preconfigured")
319337
self._logger.init = True # type: ignore[attr-defined]
338+
self._logger.powertools_handler = self.logger_handler # type: ignore[attr-defined]
320339

321340
def _configure_sampling(self) -> None:
322341
"""Dynamically set log level based on sampling rate
@@ -613,7 +632,7 @@ def structure_logs(self, append: bool = False, formatter_options: Optional[Dict]
613632

614633
# Mode 1
615634
log_keys = {**self._default_log_keys, **keys}
616-
is_logger_preconfigured = getattr(self._logger, "init", False)
635+
is_logger_preconfigured = getattr(self._logger, LOGGER_ATTRIBUTE_PRECONFIGURED, False)
617636
if not is_logger_preconfigured:
618637
formatter = self.logger_formatter or LambdaPowertoolsFormatter(**formatter_options, **log_keys)
619638
self.registered_handler.setFormatter(formatter)
@@ -672,15 +691,20 @@ def removeFilter(self, filter: logging._FilterType) -> None: # noqa: A002 # fil
672691
@property
673692
def registered_handler(self) -> logging.Handler:
674693
"""Convenience property to access the first logger handler"""
675-
# We ignore mypy here because self.child encodes whether or not self._logger.parent is
676-
# None, mypy can't see this from context but we can
677-
handlers = self._logger.parent.handlers if self.child else self._logger.handlers # type: ignore[union-attr]
678-
return handlers[0]
694+
return self._get_handler()
679695

680696
@property
681697
def registered_formatter(self) -> BasePowertoolsFormatter:
682698
"""Convenience property to access the first logger formatter"""
683-
return self.registered_handler.formatter # type: ignore[return-value]
699+
handler = self.registered_handler
700+
if handler is None:
701+
raise OrphanedChildLoggerError(
702+
"Orphan child loggers cannot append nor remove keys until a parent is initialized first. "
703+
"To solve this issue, you can A) make sure a parent logger is initialized first, or B) move append/remove keys operations to a later stage." # noqa: E501
704+
"Reference: https://docs.powertools.aws.dev/lambda/python/latest/core/logger/#reusing-logger-across-your-code",
705+
)
706+
707+
return cast(BasePowertoolsFormatter, handler.formatter)
684708

685709
@property
686710
def log_level(self) -> int:

aws_lambda_powertools/shared/constants.py

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
INVALID_XRAY_NAME_CHARACTERS = r"[?;*()!$~^<>]"
1010

1111
# Logger constants
12+
# maintenance: future major version should start having localized `constants.py` to ease future modularization
1213
LOGGER_LOG_SAMPLING_RATE: str = "POWERTOOLS_LOGGER_SAMPLE_RATE"
1314
LOGGER_LOG_EVENT_ENV: str = "POWERTOOLS_LOGGER_LOG_EVENT"
1415
LOGGER_LOG_DEDUPLICATION_ENV: str = "POWERTOOLS_LOG_DEDUPLICATION_DISABLED"

docs/core/logger.md

+27-32
Original file line numberDiff line numberDiff line change
@@ -475,9 +475,9 @@ You can use any of the following built-in JMESPath expressions as part of [injec
475475

476476
### Reusing Logger across your code
477477

478-
Similar to [Tracer](./tracer.md#reusing-tracer-across-your-code){target="_blank"}, a new instance that uses the same `service` name - env var or explicit parameter - will reuse a previous Logger instance. Just like `logging.getLogger("logger_name")` would in the standard library if called with the same logger name.
478+
Similar to [Tracer](./tracer.md#reusing-tracer-across-your-code){target="_blank"}, a new instance that uses the same `service` name will reuse a previous Logger instance.
479479

480-
Notice in the CloudWatch Logs output how `payment_id` appeared as expected when logging in `collect.py`.
480+
Notice in the CloudWatch Logs output how `payment_id` appears as expected when logging in `collect.py`.
481481

482482
=== "logger_reuse.py"
483483

@@ -497,17 +497,6 @@ Notice in the CloudWatch Logs output how `payment_id` appeared as expected when
497497
--8<-- "examples/logger/src/logger_reuse_output.json"
498498
```
499499

500-
???+ note "Note: About Child Loggers"
501-
Coming from standard library, you might be used to use `logging.getLogger(__name__)`. This will create a new instance of a Logger with a different name.
502-
503-
In Powertools, you can have the same effect by using `child=True` parameter: `Logger(child=True)`. This creates a new Logger instance named after `service.<module>`. All state changes will be propagated bi-directionally between Child and Parent.
504-
505-
For that reason, there could be side effects depending on the order the Child Logger is instantiated, because Child Loggers don't have a handler.
506-
507-
For example, if you instantiated a Child Logger and immediately used `logger.append_keys/remove_keys/set_correlation_id` to update logging state, this might fail if the Parent Logger wasn't instantiated.
508-
509-
In this scenario, you can either ensure any calls manipulating state are only called when a Parent Logger is instantiated (example above), or refrain from using `child=True` parameter altogether.
510-
511500
### Sampling debug logs
512501

513502
Use sampling when you want to dynamically change your log level to **DEBUG** based on a **percentage of your concurrent/cold start invocations**.
@@ -582,31 +571,37 @@ You can use import and use them as any other Logger formatter via `logger_format
582571

583572
### Migrating from other Loggers
584573

585-
If you're migrating from other Loggers, there are few key points to be aware of: [Service parameter](#the-service-parameter), [Inheriting Loggers](#inheriting-loggers), [Overriding Log records](#overriding-log-records), and [Logging exceptions](#logging-exceptions).
574+
If you're migrating from other Loggers, there are few key points to be aware of: [Service parameter](#the-service-parameter), [Child Loggers](#child-loggers), [Overriding Log records](#overriding-log-records), and [Logging exceptions](#logging-exceptions).
586575

587576
#### The service parameter
588577

589578
Service is what defines the Logger name, including what the Lambda function is responsible for, or part of (e.g payment service).
590579

591580
For Logger, the `service` is the logging key customers can use to search log operations for one or more functions - For example, **search for all errors, or messages like X, where service is payment**.
592581

593-
#### Inheriting Loggers
582+
#### Child Loggers
594583

595-
??? tip "Tip: Prefer [Logger Reuse feature](#reusing-logger-across-your-code) over inheritance unless strictly necessary, [see caveats.](#reusing-logger-across-your-code)"
596-
597-
> Python Logging hierarchy happens via the dot notation: `service`, `service.child`, `service.child_2`
598-
599-
For inheritance, Logger uses a `child=True` parameter along with `service` being the same value across Loggers.
584+
<center>
585+
```mermaid
586+
stateDiagram-v2
587+
direction LR
588+
Parent: Logger()
589+
Child: Logger(child=True)
590+
Parent --> Child: bi-directional updates
591+
Note right of Child
592+
Both have the same service
593+
end note
594+
```
595+
</center>
600596

601-
For child Loggers, we introspect the name of your module where `Logger(child=True, service="name")` is called, and we name your Logger as **{service}.{filename}**.
597+
For inheritance, Logger uses `child` parameter to ensure we don't compete with its parents config. We name child Loggers following Python's convention: _`{service}`.`{filename}`_.
602598

603-
???+ danger
604-
A common issue when migrating from other Loggers is that `service` might be defined in the parent Logger (no child param), and not defined in the child Logger:
599+
Changes are bidirectional between parents and loggers. That is, appending a key in a child or parent will ensure both have them. This means, having the same `service` name is important when instantiating them.
605600

606-
=== "logging_inheritance_bad.py"
601+
=== "logging_inheritance_good.py"
607602

608603
```python hl_lines="1 9"
609-
--8<-- "examples/logger/src/logging_inheritance_bad.py"
604+
--8<-- "examples/logger/src/logging_inheritance_good.py"
610605
```
611606

612607
=== "logging_inheritance_module.py"
@@ -615,17 +610,17 @@ For child Loggers, we introspect the name of your module where `Logger(child=Tru
615610
--8<-- "examples/logger/src/logging_inheritance_module.py"
616611
```
617612

618-
In this case, Logger will register a Logger named `payment`, and a Logger named `service_undefined`. The latter isn't inheriting from the parent, and will have no handler, resulting in no message being logged to standard output.
619-
620-
???+ tip
621-
This can be fixed by either ensuring both has the `service` value as `payment`, or simply use the environment variable `POWERTOOLS_SERVICE_NAME` to ensure service value will be the same across all Loggers when not explicitly set.
613+
There are two important side effects when using child loggers:
622614

623-
Do this instead:
615+
1. **Service name mismatch**. Logging messages will be dropped as child loggers don't have logging handlers.
616+
* Solution: use `POWERTOOLS_SERVICE_NAME` env var. Alternatively, use the same service explicit value.
617+
2. **Changing state before a parent instantiate**. Using `logger.append_keys` or `logger.remove_keys` without a parent Logger will lead to `OrphanedChildLoggerError` exception.
618+
* Solution: always initialize parent Loggers first. Alternatively, move calls to `append_keys`/`remove_keys` from the child at a later stage.
624619

625-
=== "logging_inheritance_good.py"
620+
=== "logging_inheritance_bad.py"
626621

627622
```python hl_lines="1 9"
628-
--8<-- "examples/logger/src/logging_inheritance_good.py"
623+
--8<-- "examples/logger/src/logging_inheritance_bad.py"
629624
```
630625

631626
=== "logging_inheritance_module.py"

tests/functional/test_logger.py

+61-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from aws_lambda_powertools import Logger, Tracer, set_package_logger_handler
1919
from aws_lambda_powertools.logging import correlation_paths
20-
from aws_lambda_powertools.logging.exceptions import InvalidLoggerSamplingRateError
20+
from aws_lambda_powertools.logging.exceptions import InvalidLoggerSamplingRateError, OrphanedChildLoggerError
2121
from aws_lambda_powertools.logging.formatter import (
2222
BasePowertoolsFormatter,
2323
LambdaPowertoolsFormatter,
@@ -1176,3 +1176,63 @@ def test_logger_json_unicode(stdout, service_name):
11761176

11771177
assert log["message"] == non_ascii_chars
11781178
assert log[japanese_field] == japanese_string
1179+
1180+
1181+
def test_logger_registered_handler_is_custom_handler(service_name):
1182+
# GIVEN a library or environment pre-setup a logger for us using the same name (see #4277)
1183+
class ForeignHandler(logging.StreamHandler): ...
1184+
1185+
foreign_handler = ForeignHandler()
1186+
logging.getLogger(service_name).addHandler(foreign_handler)
1187+
1188+
# WHEN Logger init with a custom handler
1189+
custom_handler = logging.StreamHandler()
1190+
logger = Logger(service=service_name, logger_handler=custom_handler)
1191+
1192+
# THEN registered handler should always return what we provided
1193+
assert logger.registered_handler is not foreign_handler
1194+
assert logger.registered_handler is custom_handler
1195+
assert logger.logger_handler is custom_handler
1196+
assert logger.handlers == [foreign_handler, custom_handler]
1197+
1198+
1199+
def test_child_logger_registered_handler_is_custom_handler(service_name):
1200+
# GIVEN
1201+
class ForeignHandler(logging.StreamHandler): ...
1202+
1203+
foreign_handler = ForeignHandler()
1204+
logging.getLogger(service_name).addHandler(foreign_handler)
1205+
1206+
custom_handler = logging.StreamHandler()
1207+
custom_handler.name = "CUSTOM HANDLER"
1208+
parent = Logger(service=service_name, logger_handler=custom_handler)
1209+
1210+
# WHEN a child Logger init
1211+
child = Logger(service=service_name, child=True)
1212+
1213+
# THEN child registered handler should always return what we provided in the parent
1214+
assert child.registered_handler is not foreign_handler
1215+
assert child.registered_handler is custom_handler
1216+
assert child.registered_handler is parent.registered_handler
1217+
1218+
1219+
def test_logger_handler_is_created_despite_env_pre_setup(service_name):
1220+
# GIVEN a library or environment pre-setup a logger for us using the same name
1221+
environment_handler = logging.StreamHandler()
1222+
logging.getLogger(service_name).addHandler(environment_handler)
1223+
1224+
# WHEN Logger init without a custom handler
1225+
logger = Logger(service=service_name)
1226+
1227+
# THEN registered handler should be Powertools default handler, not env
1228+
assert logger.registered_handler is not environment_handler
1229+
1230+
1231+
def test_child_logger_append_keys_before_parent(stdout, service_name):
1232+
# GIVEN a child Logger is initialized before its/without parent
1233+
child = Logger(stream=stdout, service=service_name, child=True)
1234+
1235+
# WHEN a child Logger appends a key
1236+
# THEN it will raise an AttributeError
1237+
with pytest.raises(OrphanedChildLoggerError):
1238+
child.append_keys(customer_id="value")

0 commit comments

Comments
 (0)