Skip to content

Commit eab398c

Browse files
mploskiMichal Ploskiheitorlessa
authored
feat(logger): clone powertools logger config to any Python logger (aws-powertools#927)
Co-authored-by: Michal Ploski <[email protected]> Co-authored-by: Heitor Lessa <[email protected]>
1 parent 12db027 commit eab398c

File tree

3 files changed

+292
-0
lines changed

3 files changed

+292
-0
lines changed
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import logging
2+
from typing import Callable, List, Optional, Set, Union
3+
4+
from .logger import Logger
5+
6+
7+
def copy_config_to_registered_loggers(
8+
source_logger: Logger,
9+
log_level: Optional[str] = None,
10+
exclude: Optional[Set[str]] = None,
11+
include: Optional[Set[str]] = None,
12+
) -> None:
13+
14+
"""Copies source Logger level and handler to all registered loggers for consistent formatting.
15+
16+
Parameters
17+
----------
18+
source_logger : Logger
19+
Powertools Logger to copy configuration from
20+
log_level : str, optional
21+
Logging level to set to registered loggers, by default uses source_logger logging level
22+
include : Optional[Set[str]], optional
23+
List of logger names to include, by default all registered loggers are included
24+
exclude : Optional[Set[str]], optional
25+
List of logger names to exclude, by default None
26+
"""
27+
28+
level = log_level or source_logger.level
29+
30+
# Assumptions: Only take parent loggers not children (dot notation rule)
31+
# Steps:
32+
# 1. Default operation: Include all registered loggers
33+
# 2. Only include set? Only add Loggers in the list and ignore all else
34+
# 3. Include and exclude set? Add Logger if it’s in include and not in exclude
35+
# 4. Only exclude set? Ignore Logger in the excluding list
36+
37+
# Exclude source logger by default
38+
if exclude:
39+
exclude.add(source_logger.name)
40+
else:
41+
exclude = set(source_logger.name)
42+
43+
# Prepare loggers set
44+
if include:
45+
loggers = include.difference(exclude)
46+
filter_func = _include_registered_loggers_filter
47+
else:
48+
loggers = exclude
49+
filter_func = _exclude_registered_loggers_filter
50+
51+
registered_loggers = _find_registered_loggers(source_logger, loggers, filter_func)
52+
for logger in registered_loggers:
53+
_configure_logger(source_logger, logger, level)
54+
55+
56+
def _include_registered_loggers_filter(loggers: Set[str]):
57+
return [logging.getLogger(name) for name in logging.root.manager.loggerDict if "." not in name and name in loggers]
58+
59+
60+
def _exclude_registered_loggers_filter(loggers: Set[str]) -> List[logging.Logger]:
61+
return [
62+
logging.getLogger(name) for name in logging.root.manager.loggerDict if "." not in name and name not in loggers
63+
]
64+
65+
66+
def _find_registered_loggers(
67+
source_logger: Logger, loggers: Set[str], filter_func: Callable[[Set[str]], List[logging.Logger]]
68+
) -> List[logging.Logger]:
69+
"""Filter root loggers based on provided parameters."""
70+
root_loggers = filter_func(loggers)
71+
source_logger.debug(f"Filtered root loggers: {root_loggers}")
72+
return root_loggers
73+
74+
75+
def _configure_logger(source_logger: Logger, logger: logging.Logger, level: Union[int, str]) -> None:
76+
logger.handlers = []
77+
logger.setLevel(level)
78+
source_logger.debug(f"Logger {logger} reconfigured to use logging level {level}")
79+
for source_handler in source_logger.handlers:
80+
logger.addHandler(source_handler)
81+
source_logger.debug(f"Logger {logger} reconfigured to use {source_handler}")

docs/core/logger.md

+18
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,24 @@ def handler(event: Dict, context: LambdaContext) -> List:
10661066
return response.get("Buckets", [])
10671067
```
10681068

1069+
**How can I enable powertools logging for imported libraries?**
1070+
1071+
You can copy the Logger setup to all or sub-sets of registered external loggers. Use the `copy_config_to_registered_logger` method to do this. By default all registered loggers will be modified. You can change this behaviour by providing `include` and `exclude` attributes. You can also provide optional `log_level` attribute external loggers will be configured with.
1072+
1073+
1074+
```python hl_lines="10" title="Cloning Logger config to all other registered standard loggers"
1075+
import logging
1076+
1077+
from aws_lambda_powertools import Logger
1078+
from aws_lambda_powertools.logging import utils
1079+
1080+
logger = Logger()
1081+
1082+
external_logger = logging.logger()
1083+
1084+
utils.copy_config_to_registered_loggers(source_logger=logger)
1085+
external_logger.info("test message")
1086+
10691087
**What's the difference between `append_keys` and `extra`?**
10701088

10711089
Keys added with `append_keys` will persist across multiple log messages while keys added via `extra` will only be available in a given log message operation.

tests/functional/test_logger_utils.py

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import io
2+
import json
3+
import logging
4+
import random
5+
import string
6+
from enum import Enum
7+
8+
import pytest
9+
10+
from aws_lambda_powertools import Logger
11+
from aws_lambda_powertools.logging import formatter, utils
12+
13+
14+
@pytest.fixture
15+
def stdout():
16+
return io.StringIO()
17+
18+
19+
@pytest.fixture
20+
def log_level():
21+
class LogLevel(Enum):
22+
NOTSET = 0
23+
INFO = 20
24+
WARNING = 30
25+
CRITICAL = 50
26+
27+
return LogLevel
28+
29+
30+
@pytest.fixture
31+
def logger(stdout, log_level):
32+
def _logger():
33+
logging.basicConfig(stream=stdout, level=log_level.NOTSET.value)
34+
logger = logging.getLogger(name=service_name())
35+
return logger
36+
37+
return _logger
38+
39+
40+
def capture_logging_output(stdout):
41+
return json.loads(stdout.getvalue().strip())
42+
43+
44+
def capture_multiple_logging_statements_output(stdout):
45+
return [json.loads(line.strip()) for line in stdout.getvalue().split("\n") if line]
46+
47+
48+
def service_name():
49+
chars = string.ascii_letters + string.digits
50+
return "".join(random.SystemRandom().choice(chars) for _ in range(15))
51+
52+
53+
def test_copy_config_to_ext_loggers(stdout, logger, log_level):
54+
55+
msg = "test message"
56+
57+
# GIVEN a external logger and powertools logger initialized
58+
logger_1 = logger()
59+
logger_2 = logger()
60+
61+
powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout)
62+
63+
# WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used
64+
utils.copy_config_to_registered_loggers(source_logger=powertools_logger)
65+
logger_1.info(msg)
66+
logger_2.info(msg)
67+
logs = capture_multiple_logging_statements_output(stdout)
68+
69+
# THEN
70+
for index, logger in enumerate([logger_1, logger_2]):
71+
assert len(logger.handlers) == 1
72+
assert type(logger.handlers[0]) is logging.StreamHandler
73+
assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter
74+
assert logger.level == log_level.INFO.value
75+
assert logs[index]["message"] == msg
76+
assert logs[index]["level"] == log_level.INFO.name
77+
78+
79+
def test_copy_config_to_ext_loggers_include(stdout, logger, log_level):
80+
81+
msg = "test message"
82+
83+
# GIVEN a external logger and powertools logger initialized
84+
logger = logger()
85+
powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout)
86+
87+
# WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used
88+
utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={logger.name})
89+
logger.info(msg)
90+
log = capture_logging_output(stdout)
91+
92+
# THEN
93+
assert len(logger.handlers) == 1
94+
assert type(logger.handlers[0]) is logging.StreamHandler
95+
assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter
96+
assert logger.level == log_level.INFO.value
97+
assert log["message"] == msg
98+
assert log["level"] == log_level.INFO.name
99+
100+
101+
def test_copy_config_to_ext_loggers_wrong_include(stdout, logger, log_level):
102+
103+
# GIVEN a external logger and powertools logger initialized
104+
logger = logger()
105+
powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout)
106+
107+
# WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used
108+
utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={"non-existing-logger"})
109+
110+
# THEN
111+
assert not logger.handlers
112+
113+
114+
def test_copy_config_to_ext_loggers_exclude(stdout, logger, log_level):
115+
116+
# GIVEN a external logger and powertools logger initialized
117+
logger = logger()
118+
powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout)
119+
120+
# WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used
121+
utils.copy_config_to_registered_loggers(source_logger=powertools_logger, exclude={logger.name})
122+
123+
# THEN
124+
assert not logger.handlers
125+
126+
127+
def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level):
128+
129+
msg = "test message"
130+
131+
# GIVEN a external logger and powertools logger initialized
132+
logger_1 = logger()
133+
logger_2 = logger()
134+
135+
powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout)
136+
137+
# WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used
138+
utils.copy_config_to_registered_loggers(
139+
source_logger=powertools_logger, include={logger_1.name, logger_2.name}, exclude={logger_1.name}
140+
)
141+
logger_2.info(msg)
142+
log = capture_logging_output(stdout)
143+
144+
# THEN
145+
assert not logger_1.handlers
146+
assert len(logger_2.handlers) == 1
147+
assert type(logger_2.handlers[0]) is logging.StreamHandler
148+
assert type(logger_2.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter
149+
assert logger_2.level == log_level.INFO.value
150+
assert log["message"] == msg
151+
assert log["level"] == log_level.INFO.name
152+
153+
154+
def test_copy_config_to_ext_loggers_clean_old_handlers(stdout, logger, log_level):
155+
156+
# GIVEN a external logger with handler and powertools logger initialized
157+
logger = logger()
158+
handler = logging.FileHandler("logfile")
159+
logger.addHandler(handler)
160+
powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout)
161+
162+
# WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used
163+
utils.copy_config_to_registered_loggers(source_logger=powertools_logger)
164+
165+
# THEN
166+
assert len(logger.handlers) == 1
167+
assert type(logger.handlers[0]) is logging.StreamHandler
168+
assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter
169+
170+
171+
def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level):
172+
173+
msg = "test message"
174+
175+
# GIVEN a external logger and powertools logger initialized
176+
logger = logger()
177+
powertools_logger = Logger(service=service_name(), level=log_level.CRITICAL.value, stream=stdout)
178+
level = log_level.WARNING.name
179+
180+
# WHEN configuration copied from powertools logger to ALL external loggers
181+
# AND our external logger used with custom log_level
182+
utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={logger.name}, log_level=level)
183+
logger.warning(msg)
184+
log = capture_logging_output(stdout)
185+
186+
# THEN
187+
assert len(logger.handlers) == 1
188+
assert type(logger.handlers[0]) is logging.StreamHandler
189+
assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter
190+
assert powertools_logger.level == log_level.CRITICAL.value
191+
assert logger.level == log_level.WARNING.value
192+
assert log["message"] == msg
193+
assert log["level"] == log_level.WARNING.name

0 commit comments

Comments
 (0)