Skip to content

Commit d95fd21

Browse files
committed
Merge branch 'develop' into issue/242
* develop: chore: move env names to constant file (#264) docs: fix import (#267) feat: Add AppConfig parameter provider (#236) chore: update stale bot improv: override Tracer auto-capture response/exception via env vars (#259) docs: add info about extras layer (#260) feat: support extra parameter in Logger messages (#257) chore: general simplifications and cleanup (#255)
2 parents e854efe + 454d669 commit d95fd21

27 files changed

+812
-177
lines changed

.github/stale.yml

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
only: issues
2-
daysUntilStale: 30
2+
daysUntilStale: 14
33
daysUntilClose: 7
44
exemptLabels:
55
- bug
6-
- documentation
7-
- enhancement
86
- feature-request
9-
- RFC
107
staleLabel: pending-close-response-required
118
markComment: >
129
This issue has been automatically marked as stale because it has not had

aws_lambda_powertools/logging/filters.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ def filter(self, record): # noqa: A003
1313
created by loggers who don't have a handler.
1414
"""
1515
logger = record.name
16-
return False if self.logger in logger else True
16+
return self.logger not in logger
+112-39
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
11
import json
22
import logging
33
import os
4+
from typing import Dict, Iterable, Optional, Union
5+
6+
from ..shared import constants
7+
8+
STD_LOGGING_KEYS = (
9+
"name",
10+
"msg",
11+
"args",
12+
"levelname",
13+
"levelno",
14+
"pathname",
15+
"filename",
16+
"module",
17+
"exc_info",
18+
"exc_text",
19+
"stack_info",
20+
"lineno",
21+
"funcName",
22+
"created",
23+
"msecs",
24+
"relativeCreated",
25+
"thread",
26+
"threadName",
27+
"processName",
28+
"process",
29+
"asctime",
30+
)
431

532

633
class JsonFormatter(logging.Formatter):
@@ -30,12 +57,12 @@ def __init__(self, **kwargs):
3057
# Set the default unserializable function, by default values will be cast as str.
3158
self.default_json_formatter = kwargs.pop("json_default", str)
3259
# Set the insertion order for the log messages
33-
self.format_dict = dict.fromkeys(kwargs.pop("log_record_order", ["level", "location", "message", "timestamp"]))
60+
self.log_format = dict.fromkeys(kwargs.pop("log_record_order", ["level", "location", "message", "timestamp"]))
3461
self.reserved_keys = ["timestamp", "level", "location"]
3562
# Set the date format used by `asctime`
3663
super(JsonFormatter, self).__init__(datefmt=kwargs.pop("datefmt", None))
3764

38-
self.format_dict.update(self._build_root_keys(**kwargs))
65+
self.log_format.update(self._build_root_keys(**kwargs))
3966

4067
@staticmethod
4168
def _build_root_keys(**kwargs):
@@ -48,53 +75,99 @@ def _build_root_keys(**kwargs):
4875

4976
@staticmethod
5077
def _get_latest_trace_id():
51-
xray_trace_id = os.getenv("_X_AMZN_TRACE_ID")
52-
trace_id = xray_trace_id.split(";")[0].replace("Root=", "") if xray_trace_id else None
53-
54-
return trace_id
78+
xray_trace_id = os.getenv(constants.XRAY_TRACE_ID_ENV)
79+
return xray_trace_id.split(";")[0].replace("Root=", "") if xray_trace_id else None
5580

5681
def update_formatter(self, **kwargs):
57-
self.format_dict.update(kwargs)
82+
self.log_format.update(kwargs)
5883

59-
def format(self, record): # noqa: A003
60-
record_dict = record.__dict__.copy()
61-
record_dict["asctime"] = self.formatTime(record, self.datefmt)
84+
@staticmethod
85+
def _extract_log_message(log_record: logging.LogRecord) -> Union[Dict, str, bool, Iterable]:
86+
"""Extract message from log record and attempt to JSON decode it
87+
88+
Parameters
89+
----------
90+
log_record : logging.LogRecord
91+
Log record to extract message from
92+
93+
Returns
94+
-------
95+
message: Union[Dict, str, bool, Iterable]
96+
Extracted message
97+
"""
98+
if isinstance(log_record.msg, dict):
99+
return log_record.msg
62100

63-
log_dict = {}
101+
message: str = log_record.getMessage()
64102

65-
for key, value in self.format_dict.items():
66-
if value and key in self.reserved_keys:
67-
# converts default logging expr to its record value
68-
# e.g. '%(asctime)s' to '2020-04-24 09:35:40,698'
69-
log_dict[key] = value % record_dict
70-
else:
71-
log_dict[key] = value
103+
# Attempt to decode non-str messages e.g. msg = '{"x": "y"}'
104+
try:
105+
message = json.loads(log_record.msg)
106+
except (json.decoder.JSONDecodeError, TypeError, ValueError):
107+
pass
108+
109+
return message
110+
111+
def _extract_log_exception(self, log_record: logging.LogRecord) -> Optional[str]:
112+
"""Format traceback information, if available
72113
73-
if isinstance(record_dict["msg"], dict):
74-
log_dict["message"] = record_dict["msg"]
75-
else:
76-
log_dict["message"] = record.getMessage()
114+
Parameters
115+
----------
116+
log_record : logging.LogRecord
117+
Log record to extract message from
118+
119+
Returns
120+
-------
121+
log_record: Optional[str]
122+
Log record with constant traceback info
123+
"""
124+
if log_record.exc_info:
125+
return self.formatException(log_record.exc_info)
77126

78-
# Attempt to decode the message as JSON, if so, merge it with the
79-
# overall message for clarity.
80-
try:
81-
log_dict["message"] = json.loads(log_dict["message"])
82-
except (json.decoder.JSONDecodeError, TypeError, ValueError):
83-
pass
127+
return None
84128

85-
if record.exc_info and not record.exc_text:
86-
# Cache the traceback text to avoid converting it multiple times
87-
# (it's constant anyway)
88-
# from logging.Formatter:format
89-
record.exc_text = self.formatException(record.exc_info)
129+
def _extract_log_keys(self, log_record: logging.LogRecord) -> Dict:
130+
"""Extract and parse custom and reserved log keys
90131
91-
if record.exc_text:
92-
log_dict["exception"] = record.exc_text
132+
Parameters
133+
----------
134+
log_record : logging.LogRecord
135+
Log record to extract keys from
93136
94-
# fetch latest X-Ray Trace ID, if any
95-
log_dict.update({"xray_trace_id": self._get_latest_trace_id()})
137+
Returns
138+
-------
139+
formatted_log: Dict
140+
Structured log as dictionary
141+
"""
142+
record_dict = log_record.__dict__.copy() # has extra kwargs we are after
143+
record_dict["asctime"] = self.formatTime(log_record, self.datefmt)
144+
145+
formatted_log = {}
146+
147+
# We have to iterate over a default or existing log structure
148+
# then replace any logging expression for reserved keys e.g. '%(level)s' to 'INFO'
149+
# and lastly add or replace incoming keys (those added within the constructor or .structure_logs method)
150+
for key, value in self.log_format.items():
151+
if value and key in self.reserved_keys:
152+
formatted_log[key] = value % record_dict
153+
else:
154+
formatted_log[key] = value
155+
156+
# pick up extra keys when logging a new message e.g. log.info("my message", extra={"additional_key": "value"}
157+
# these messages will be added to the root of the final structure not within `message` key
158+
for key, value in record_dict.items():
159+
if key not in STD_LOGGING_KEYS:
160+
formatted_log[key] = value
161+
162+
return formatted_log
163+
164+
def format(self, record): # noqa: A003
165+
formatted_log = self._extract_log_keys(log_record=record)
166+
formatted_log["message"] = self._extract_log_message(log_record=record)
167+
formatted_log["exception"] = self._extract_log_exception(log_record=record)
168+
formatted_log.update({"xray_trace_id": self._get_latest_trace_id()}) # fetch latest Trace ID, if any
96169

97170
# Filter out top level key with values that are None
98-
log_dict = {k: v for k, v in log_dict.items() if v is not None}
171+
formatted_log = {k: v for k, v in formatted_log.items() if v is not None}
99172

100-
return json.dumps(log_dict, default=self.default_json_formatter)
173+
return json.dumps(formatted_log, default=self.default_json_formatter)

aws_lambda_powertools/logging/logger.py

+16-11
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import os
55
import random
66
import sys
7-
from distutils.util import strtobool
87
from typing import Any, Callable, Dict, Union
98

9+
from ..shared import constants
10+
from ..shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice
1011
from .exceptions import InvalidLoggerSamplingRateError
1112
from .filters import SuppressFilter
1213
from .formatter import JsonFormatter
@@ -122,8 +123,12 @@ def __init__(
122123
stream: sys.stdout = None,
123124
**kwargs,
124125
):
125-
self.service = service or os.getenv("POWERTOOLS_SERVICE_NAME") or "service_undefined"
126-
self.sampling_rate = sampling_rate or os.getenv("POWERTOOLS_LOGGER_SAMPLE_RATE") or 0.0
126+
self.service = resolve_env_var_choice(
127+
choice=service, env=os.getenv(constants.SERVICE_NAME_ENV, "service_undefined")
128+
)
129+
self.sampling_rate = resolve_env_var_choice(
130+
choice=sampling_rate, env=os.getenv(constants.LOGGER_LOG_SAMPLING_RATE, 0.0)
131+
)
127132
self.log_level = self._get_log_level(level)
128133
self.child = child
129134
self._handler = logging.StreamHandler(stream) if stream is not None else logging.StreamHandler(sys.stdout)
@@ -193,7 +198,7 @@ def _configure_sampling(self):
193198
f"Please review POWERTOOLS_LOGGER_SAMPLE_RATE environment variable."
194199
)
195200

196-
def inject_lambda_context(self, lambda_handler: Callable[[Dict, Any], Any] = None, log_event: bool = False):
201+
def inject_lambda_context(self, lambda_handler: Callable[[Dict, Any], Any] = None, log_event: bool = None):
197202
"""Decorator to capture Lambda contextual info and inject into logger
198203
199204
Parameters
@@ -242,8 +247,9 @@ def handler(event, context):
242247
logger.debug("Decorator called with parameters")
243248
return functools.partial(self.inject_lambda_context, log_event=log_event)
244249

245-
log_event_env_option = str(os.getenv("POWERTOOLS_LOGGER_LOG_EVENT", "false"))
246-
log_event = strtobool(log_event_env_option) or log_event
250+
log_event = resolve_truthy_env_var_choice(
251+
choice=log_event, env=os.getenv(constants.LOGGER_LOG_EVENT_ENV, "false")
252+
)
247253

248254
@functools.wraps(lambda_handler)
249255
def decorate(event, context):
@@ -291,9 +297,10 @@ def _get_log_level(level: Union[str, int]) -> Union[str, int]:
291297
return level
292298

293299
log_level: str = level or os.getenv("LOG_LEVEL")
294-
log_level = log_level.upper() if log_level is not None else logging.INFO
300+
if log_level is None:
301+
return logging.INFO
295302

296-
return log_level
303+
return log_level.upper()
297304

298305
@staticmethod
299306
def _get_caller_filename():
@@ -303,9 +310,7 @@ def _get_caller_filename():
303310
# Before previous frame => Caller
304311
frame = inspect.currentframe()
305312
caller_frame = frame.f_back.f_back.f_back
306-
filename = caller_frame.f_globals["__name__"]
307-
308-
return filename
313+
return caller_frame.f_globals["__name__"]
309314

310315

311316
def set_package_logger(

aws_lambda_powertools/metrics/base.py

+5-6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import fastjsonschema
1212

13+
from ..shared import constants
14+
from ..shared.functions import resolve_env_var_choice
1315
from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError
1416

1517
logger = logging.getLogger(__name__)
@@ -88,8 +90,8 @@ def __init__(
8890
):
8991
self.metric_set = metric_set if metric_set is not None else {}
9092
self.dimension_set = dimension_set if dimension_set is not None else {}
91-
self.namespace = namespace or os.getenv("POWERTOOLS_METRICS_NAMESPACE")
92-
self.service = service or os.environ.get("POWERTOOLS_SERVICE_NAME")
93+
self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV))
94+
self.service = resolve_env_var_choice(choice=service, env=os.getenv(constants.SERVICE_NAME_ENV))
9395
self._metric_units = [unit.value for unit in MetricUnit]
9496
self._metric_unit_options = list(MetricUnit.__members__)
9597
self.metadata_set = self.metadata_set if metadata_set is not None else {}
@@ -240,10 +242,7 @@ def add_dimension(self, name: str, value: str):
240242
# Cast value to str according to EMF spec
241243
# Majority of values are expected to be string already, so
242244
# checking before casting improves performance in most cases
243-
if isinstance(value, str):
244-
self.dimension_set[name] = value
245-
else:
246-
self.dimension_set[name] = str(value)
245+
self.dimension_set[name] = value if isinstance(value, str) else str(value)
247246

248247
def add_metadata(self, key: str, value: Any):
249248
"""Adds high cardinal metadata for metrics object

aws_lambda_powertools/middleware_factory/factory.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22
import inspect
33
import logging
44
import os
5-
from distutils.util import strtobool
65
from typing import Callable
76

7+
from ..shared import constants
8+
from ..shared.functions import resolve_truthy_env_var_choice
89
from ..tracing import Tracer
910
from .exceptions import MiddlewareInvalidArgumentError
1011

1112
logger = logging.getLogger(__name__)
1213

1314

14-
def lambda_handler_decorator(decorator: Callable = None, trace_execution=False):
15+
def lambda_handler_decorator(decorator: Callable = None, trace_execution: bool = None):
1516
"""Decorator factory for decorating Lambda handlers.
1617
1718
You can use lambda_handler_decorator to create your own middlewares,
@@ -104,7 +105,9 @@ def lambda_handler(event, context):
104105
if decorator is None:
105106
return functools.partial(lambda_handler_decorator, trace_execution=trace_execution)
106107

107-
trace_execution = trace_execution or strtobool(str(os.getenv("POWERTOOLS_TRACE_MIDDLEWARES", False)))
108+
trace_execution = resolve_truthy_env_var_choice(
109+
choice=trace_execution, env=os.getenv(constants.MIDDLEWARE_FACTORY_TRACE_ENV, "false")
110+
)
108111

109112
@functools.wraps(decorator)
110113
def final_decorator(func: Callable = None, **kwargs):

aws_lambda_powertools/shared/__init__.py

Whitespace-only changes.
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
TRACER_CAPTURE_RESPONSE_ENV: str = "POWERTOOLS_TRACER_CAPTURE_RESPONSE"
2+
TRACER_CAPTURE_ERROR_ENV: str = "POWERTOOLS_TRACER_CAPTURE_ERROR"
3+
TRACER_DISABLED_ENV: str = "POWERTOOLS_TRACE_DISABLED"
4+
5+
LOGGER_LOG_SAMPLING_RATE: str = "POWERTOOLS_LOGGER_SAMPLE_RATE"
6+
LOGGER_LOG_EVENT_ENV: str = "POWERTOOLS_LOGGER_LOG_EVENT"
7+
8+
MIDDLEWARE_FACTORY_TRACE_ENV: str = "POWERTOOLS_TRACE_MIDDLEWARES"
9+
10+
METRICS_NAMESPACE_ENV: str = "POWERTOOLS_METRICS_NAMESPACE"
11+
12+
SAM_LOCAL_ENV: str = "AWS_SAM_LOCAL"
13+
CHALICE_LOCAL_ENV: str = "AWS_CHALICE_CLI_MODE"
14+
SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME"
15+
XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID"
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from distutils.util import strtobool
2+
from typing import Any, Union
3+
4+
5+
def resolve_truthy_env_var_choice(env: Any, choice: bool = None) -> bool:
6+
""" Pick explicit choice over truthy env value, if available, otherwise return truthy env value
7+
8+
NOTE: Environment variable should be resolved by the caller.
9+
10+
Parameters
11+
----------
12+
env : Any
13+
environment variable actual value
14+
choice : bool
15+
explicit choice
16+
17+
Returns
18+
-------
19+
choice : str
20+
resolved choice as either bool or environment value
21+
"""
22+
return choice if choice is not None else strtobool(env)
23+
24+
25+
def resolve_env_var_choice(env: Any, choice: bool = None) -> Union[bool, Any]:
26+
""" Pick explicit choice over env, if available, otherwise return env value received
27+
28+
NOTE: Environment variable should be resolved by the caller.
29+
30+
Parameters
31+
----------
32+
env : Any
33+
environment variable actual value
34+
choice : bool
35+
explicit choice
36+
37+
Returns
38+
-------
39+
choice : str
40+
resolved choice as either bool or environment value
41+
"""
42+
return choice if choice is not None else env

0 commit comments

Comments
 (0)