Skip to content

Commit 8939535

Browse files
authored
feat(logger): add use_rfc3339 and auto-complete formatter opts in Logger (#1662)
1 parent 2c26f84 commit 8939535

File tree

8 files changed

+161
-36
lines changed

8 files changed

+161
-36
lines changed

Diff for: aws_lambda_powertools/logging/formatter.py

+27-6
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class LambdaPowertoolsFormatter(BasePowertoolsFormatter):
6262

6363
default_time_format = "%Y-%m-%d %H:%M:%S,%F%z" # '2021-04-17 18:19:57,656+0200'
6464
custom_ms_time_directive = "%F"
65+
RFC3339_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S.%F%z" # '2022-10-27T16:27:43.738+02:00'
6566

6667
def __init__(
6768
self,
@@ -72,6 +73,7 @@ def __init__(
7273
use_datetime_directive: bool = False,
7374
log_record_order: Optional[List[str]] = None,
7475
utc: bool = False,
76+
use_rfc3339: bool = False,
7577
**kwargs,
7678
):
7779
"""Return a LambdaPowertoolsFormatter instance.
@@ -106,6 +108,9 @@ def __init__(
106108
also supports a custom %F directive for milliseconds.
107109
utc : bool, optional
108110
set logging timestamp to UTC, by default False to continue to use local time as per stdlib
111+
use_rfc3339: bool, optional
112+
Whether to use a popular dateformat that complies with both RFC3339 and ISO8601.
113+
e.g., 2022-10-27T16:27:43.738+02:00.
109114
log_record_order : list, optional
110115
set order of log keys when logging, by default ["level", "location", "message", "timestamp"]
111116
kwargs
@@ -129,6 +134,7 @@ def __init__(
129134
self.log_record_order = log_record_order or ["level", "location", "message", "timestamp"]
130135
self.log_format = dict.fromkeys(self.log_record_order) # Set the insertion order for the log messages
131136
self.update_formatter = self.append_keys # alias to old method
137+
self.use_rfc3339_iso8601 = use_rfc3339
132138

133139
if self.utc:
134140
self.converter = time.gmtime # type: ignore
@@ -153,36 +159,51 @@ def format(self, record: logging.LogRecord) -> str: # noqa: A003
153159
return self.serialize(log=formatted_log)
154160

155161
def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
162+
# As of Py3.7, we can infer milliseconds directly from any datetime
163+
# saving processing time as we can shortcircuit early
164+
# Maintenance: In V3, we (and Java) should move to this format by default
165+
# since we've provided enough time for those migrating from std logging
166+
if self.use_rfc3339_iso8601:
167+
if self.utc:
168+
ts_as_datetime = datetime.fromtimestamp(record.created, tz=timezone.utc)
169+
else:
170+
ts_as_datetime = datetime.fromtimestamp(record.created).astimezone()
171+
172+
return ts_as_datetime.isoformat(timespec="milliseconds") # 2022-10-27T17:42:26.841+0200
173+
174+
# converts to local/UTC TZ as struct time
156175
record_ts = self.converter(record.created) # type: ignore
157176

158177
if datefmt is None: # pragma: no cover, it'll always be None in std logging, but mypy
159178
datefmt = self.datefmt
160179

161180
# NOTE: Python `time.strftime` doesn't provide msec directives
162-
# so we create a custom one (%F) and replace logging record ts
181+
# so we create a custom one (%F) and replace logging record_ts
163182
# Reason 2 is that std logging doesn't support msec after TZ
164183
msecs = "%03d" % record.msecs
165184

166-
# Datetime format codes might be optionally used
167-
# however it only makes a difference if `datefmt` is passed
168-
# since format codes are the same except %f
185+
# Datetime format codes is a superset of time format codes
186+
# therefore we only honour them if explicitly asked
187+
# by default, those migrating from std logging will use time format codes
188+
# https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
169189
if self.use_datetime_directive and datefmt:
170-
# record.msecs are microseconds, divide by 1000 and we get milliseconds
190+
# record.msecs are microseconds, divide by 1000 to get milliseconds
171191
timestamp = record.created + record.msecs / 1000
172192

173193
if self.utc:
174194
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
175195
else:
176-
# make sure local timezone is included
177196
dt = datetime.fromtimestamp(timestamp).astimezone()
178197

179198
custom_fmt = datefmt.replace(self.custom_ms_time_directive, msecs)
180199
return dt.strftime(custom_fmt)
181200

201+
# Only time format codes being used
182202
elif datefmt:
183203
custom_fmt = datefmt.replace(self.custom_ms_time_directive, msecs)
184204
return time.strftime(custom_fmt, record_ts)
185205

206+
# Use default fmt: 2021-05-03 10:20:19,650+0200
186207
custom_fmt = self.default_time_format.replace(self.custom_ms_time_directive, msecs)
187208
return time.strftime(custom_fmt, record_ts)
188209

Diff for: aws_lambda_powertools/logging/logger.py

+47-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@
66
import random
77
import sys
88
import traceback
9-
from typing import IO, Any, Callable, Dict, Iterable, Mapping, Optional, TypeVar, Union
9+
from typing import (
10+
IO,
11+
Any,
12+
Callable,
13+
Dict,
14+
Iterable,
15+
List,
16+
Mapping,
17+
Optional,
18+
TypeVar,
19+
Union,
20+
)
1021

1122
import jmespath
1223

@@ -86,14 +97,16 @@ class Logger(logging.Logger): # lgtm [py/missing-call-to-init]
8697
Parameters propagated to LambdaPowertoolsFormatter
8798
--------------------------------------------------
8899
datefmt: str, optional
89-
String directives (strftime) to format log timestamp using `time`, by default it uses RFC
90-
3339.
100+
String directives (strftime) to format log timestamp using `time`, by default it uses 2021-05-03 11:47:12,494+0200. # noqa: E501
91101
use_datetime_directive: bool, optional
92102
Interpret `datefmt` as a format string for `datetime.datetime.strftime`, rather than
93103
`time.strftime`.
94104
95105
See https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior . This
96106
also supports a custom %F directive for milliseconds.
107+
use_rfc3339: bool, optional
108+
Whether to use a popular date format that complies with both RFC3339 and ISO8601.
109+
e.g., 2022-10-27T16:27:43.738+02:00.
97110
json_serializer : Callable, optional
98111
function to serialize `obj` to a JSON formatted `str`, by default json.dumps
99112
json_deserializer : Callable, optional
@@ -187,6 +200,14 @@ def __init__(
187200
stream: Optional[IO[str]] = None,
188201
logger_formatter: Optional[PowertoolsFormatter] = None,
189202
logger_handler: Optional[logging.Handler] = None,
203+
json_serializer: Optional[Callable[[Dict], str]] = None,
204+
json_deserializer: Optional[Callable[[Union[Dict, str, bool, int, float]], str]] = None,
205+
json_default: Optional[Callable[[Any], Any]] = None,
206+
datefmt: Optional[str] = None,
207+
use_datetime_directive: bool = False,
208+
log_record_order: Optional[List[str]] = None,
209+
utc: bool = False,
210+
use_rfc3339: bool = False,
190211
**kwargs,
191212
):
192213
self.service = resolve_env_var_choice(
@@ -205,7 +226,20 @@ def __init__(
205226
self._default_log_keys = {"service": self.service, "sampling_rate": self.sampling_rate}
206227
self._logger = self._get_logger()
207228

208-
self._init_logger(**kwargs)
229+
# NOTE: This is primarily to improve UX, so IDEs can autocomplete LambdaPowertoolsFormatter options
230+
# previously, we masked all of them as kwargs thus limiting feature discovery
231+
formatter_options = {
232+
"json_serializer": json_serializer,
233+
"json_deserializer": json_deserializer,
234+
"json_default": json_default,
235+
"datefmt": datefmt,
236+
"use_datetime_directive": use_datetime_directive,
237+
"log_record_order": log_record_order,
238+
"utc": utc,
239+
"use_rfc3339": use_rfc3339,
240+
}
241+
242+
self._init_logger(formatter_options=formatter_options, **kwargs)
209243

210244
def __getattr__(self, name):
211245
# Proxy attributes not found to actual logger to support backward compatibility
@@ -220,7 +254,7 @@ def _get_logger(self):
220254

221255
return logging.getLogger(logger_name)
222256

223-
def _init_logger(self, **kwargs):
257+
def _init_logger(self, formatter_options: Optional[Dict] = None, **kwargs):
224258
"""Configures new logger"""
225259

226260
# Skip configuration if it's a child logger or a pre-configured logger
@@ -235,7 +269,7 @@ def _init_logger(self, **kwargs):
235269
self._configure_sampling()
236270
self._logger.setLevel(self.log_level)
237271
self._logger.addHandler(self.logger_handler)
238-
self.structure_logs(**kwargs)
272+
self.structure_logs(formatter_options=formatter_options, **kwargs)
239273

240274
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
241275
self._logger.findCaller = self.findCaller
@@ -501,19 +535,23 @@ def registered_formatter(self) -> BasePowertoolsFormatter:
501535
"""Convenience property to access logger formatter"""
502536
return self.registered_handler.formatter # type: ignore
503537

504-
def structure_logs(self, append: bool = False, **keys):
538+
def structure_logs(self, append: bool = False, formatter_options: Optional[Dict] = None, **keys):
505539
"""Sets logging formatting to JSON.
506540
507541
Optionally, it can append keyword arguments
508-
to an existing logger so it is available across future log statements.
542+
to an existing logger, so it is available across future log statements.
509543
510544
Last keyword argument and value wins if duplicated.
511545
512546
Parameters
513547
----------
514548
append : bool, optional
515549
append keys provided to logger formatter, by default False
550+
formatter_options : dict, optional
551+
LambdaPowertoolsFormatter options to be propagated, by default {}
516552
"""
553+
formatter_options = formatter_options or {}
554+
517555
# There are 3 operational modes for this method
518556
## 1. Register a Powertools Formatter for the first time
519557
## 2. Append new keys to the current logger formatter; deprecated in favour of append_keys
@@ -523,7 +561,7 @@ def structure_logs(self, append: bool = False, **keys):
523561
log_keys = {**self._default_log_keys, **keys}
524562
is_logger_preconfigured = getattr(self._logger, "init", False)
525563
if not is_logger_preconfigured:
526-
formatter = self.logger_formatter or LambdaPowertoolsFormatter(**log_keys) # type: ignore
564+
formatter = self.logger_formatter or LambdaPowertoolsFormatter(**formatter_options, **log_keys) # type: ignore # noqa: E501
527565
self.registered_handler.setFormatter(formatter)
528566

529567
# when using a custom Lambda Powertools Formatter

Diff for: docs/core/logger.md

+27-8
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,30 @@ Use `logger.exception` method to log contextual information about exceptions. Lo
291291
--8<-- "examples/logger/src/logging_exceptions_output.json"
292292
```
293293

294+
### Date formatting
295+
296+
Logger uses Python's standard logging date format with the addition of timezone: `2021-05-03 11:47:12,494+0200`.
297+
298+
You can easily change the date format using one of the following parameters:
299+
300+
* **`datefmt`**. You can pass any [strftime format codes](https://strftime.org/){target="_blank"}. Use `%F` if you need milliseconds.
301+
* **`use_rfc3339`**. This flag will use a format compliant with both RFC3339 and ISO8601: `2022-10-27T16:27:43.738+02:00`
302+
303+
???+ tip "Prefer using [datetime string formats](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes){target="_blank"}?"
304+
Use `use_datetime_directive` flag along with `datefmt` to instruct Logger to use `datetime` instead of `time.strftime`.
305+
306+
=== "date_formatting.py"
307+
308+
```python hl_lines="5 8"
309+
--8<-- "examples/logger/src/date_formatting.py"
310+
```
311+
312+
=== "date_formatting_output.json"
313+
314+
```json hl_lines="6 13"
315+
--8<-- "examples/logger/src/date_formatting_output.json"
316+
```
317+
294318
## Advanced
295319

296320
### Built-in Correlation ID expressions
@@ -451,24 +475,19 @@ Do this instead:
451475

452476
#### Overriding Log records
453477

454-
???+ tip
455-
Use `datefmt` for custom date formats - We honour standard [logging library string formats](https://docs.python.org/3/howto/logging.html#displaying-the-date-time-in-messages){target="_blank"}.
456-
457-
Prefer using [datetime string formats](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes){target="_blank"}? Set `use_datetime_directive` at Logger constructor or at [Lambda Powertools Formatter](#lambdapowertoolsformatter).
458-
459478
You might want to continue to use the same date formatting style, or override `location` to display the `package.function_name:line_number` as you previously had.
460479

461-
Logger allows you to either change the format or suppress the following keys altogether at the initialization: `location`, `timestamp`, `level`, `xray_trace_id`.
480+
Logger allows you to either change the format or suppress the following keys at initialization: `location`, `timestamp`, `xray_trace_id`.
462481

463482
=== "overriding_log_records.py"
464483

465-
```python hl_lines="7 10"
484+
```python hl_lines="6 10"
466485
--8<-- "examples/logger/src/overriding_log_records.py"
467486
```
468487

469488
=== "overriding_log_records_output.json"
470489

471-
```json hl_lines="3 5"
490+
```json hl_lines="4"
472491
--8<-- "examples/logger/src/overriding_log_records_output.json"
473492
```
474493

Diff for: examples/logger/src/date_formatting.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from aws_lambda_powertools import Logger
2+
3+
date_format = "%m/%d/%Y %I:%M:%S %p"
4+
5+
logger = Logger(service="payment", use_rfc3339=True)
6+
logger.info("Collecting payment")
7+
8+
logger_custom_format = Logger(service="loyalty", datefmt=date_format)
9+
logger_custom_format.info("Calculating points")

Diff for: examples/logger/src/date_formatting_output.json

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
{
3+
"level": "INFO",
4+
"location": "<module>:6",
5+
"message": "Collecting payment",
6+
"timestamp": "2022-10-28T14:35:03.210+02:00",
7+
"service": "payment"
8+
},
9+
{
10+
"level": "INFO",
11+
"location": "<module>:9",
12+
"message": "Calculating points",
13+
"timestamp": "10/28/2022 02:35:03 PM",
14+
"service": "loyalty"
15+
}
16+
]

Diff for: examples/logger/src/overriding_log_records.py

+5-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
from aws_lambda_powertools import Logger
22

3-
date_format = "%m/%d/%Y %I:%M:%S %p"
43
location_format = "[%(funcName)s] %(module)s"
54

65
# override location and timestamp format
7-
logger = Logger(service="payment", location=location_format, datefmt=date_format)
8-
9-
# suppress the location key with a None value
10-
logger_two = Logger(service="payment", location=None)
11-
6+
logger = Logger(service="payment", location=location_format)
127
logger.info("Collecting payment")
8+
9+
# suppress keys with a None value
10+
logger_two = Logger(service="loyalty", location=None)
11+
logger_two.info("Calculating points")
+15-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
{
2-
"level": "INFO",
3-
"location": "[<module>] lambda_handler",
4-
"message": "Collecting payment",
5-
"timestamp": "02/09/2021 09:25:17 AM",
6-
"service": "payment"
7-
}
1+
[
2+
{
3+
"level": "INFO",
4+
"location": "[<module>] overriding_log_records",
5+
"message": "Collecting payment",
6+
"timestamp": "2022-10-28 14:40:43,801+0200",
7+
"service": "payment"
8+
},
9+
{
10+
"level": "INFO",
11+
"message": "Calculating points",
12+
"timestamp": "2022-10-28 14:40:43,801+0200",
13+
"service": "loyalty"
14+
}
15+
]

Diff for: tests/functional/test_logger.py

+15
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,21 @@ def test_use_datetime(stdout, service_name, utc):
814814
)
815815

816816

817+
@pytest.mark.parametrize("utc", [False, True])
818+
def test_use_rfc3339_iso8601(stdout, service_name, utc):
819+
# GIVEN
820+
logger = Logger(service=service_name, stream=stdout, use_rfc3339=True, utc=utc)
821+
RFC3339_REGEX = r"^((?:(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?))(Z|[\+-]\d{2}:\d{2})?)$"
822+
823+
# WHEN a log statement happens
824+
logger.info({})
825+
826+
# THEN the timestamp has the appropriate formatting
827+
log = capture_logging_output(stdout)
828+
829+
assert re.fullmatch(RFC3339_REGEX, log["timestamp"]) # "2022-10-27T17:42:26.841+0200"
830+
831+
817832
def test_inject_lambda_context_log_event_request_data_classes(lambda_context, stdout, lambda_event, service_name):
818833
# GIVEN Logger is initialized
819834
logger = Logger(service=service_name, stream=stdout)

0 commit comments

Comments
 (0)