Skip to content

Commit 38e5f59

Browse files
committed
Add 'datetime_fmt' for log message timestamps using datetime
1 parent cd15ee9 commit 38e5f59

File tree

3 files changed

+62
-6
lines changed

3 files changed

+62
-6
lines changed

aws_lambda_powertools/logging/formatter.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import time
55
from abc import ABCMeta, abstractmethod
6+
from datetime import datetime, timezone
67
from functools import partial
78
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
89

@@ -61,9 +62,10 @@ def __init__(
6162
json_deserializer: Optional[Callable[[Union[Dict, str, bool, int, float]], str]] = None,
6263
json_default: Optional[Callable[[Any], Any]] = None,
6364
datefmt: Optional[str] = None,
65+
datetime_fmt: Optional[str] = None,
6466
log_record_order: Optional[List[str]] = None,
6567
utc: bool = False,
66-
**kwargs
68+
**kwargs,
6769
):
6870
"""Return a LambdaPowertoolsFormatter instance.
6971
@@ -86,20 +88,33 @@ def __init__(
8688
Only used when no custom JSON encoder is set
8789
8890
datefmt : str, optional
89-
String directives (strftime) to format log timestamp
91+
String directives (strftime) to format log timestamp using `time`. Only one of `datefmt`
92+
and `datetime_fmt` should be specified.
9093
9194
See https://docs.python.org/3/library/time.html#time.strftime
95+
datetime_fmt : str, optional
96+
String directives (strftime) to format log timestamp using `datetime`. Only one of
97+
`datefmt` and `datetime_fmt` should be specified.
98+
99+
See https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior . This
100+
also supports a custom %F directive for milliseconds.
92101
utc : bool, optional
93102
set logging timestamp to UTC, by default False to continue to use local time as per stdlib
94103
log_record_order : list, optional
95104
set order of log keys when logging, by default ["level", "location", "message", "timestamp"]
96105
kwargs
97106
Key-value to be included in log messages
107+
98108
"""
99109
self.json_deserializer = json_deserializer or json.loads
100110
self.json_default = json_default or str
101111
self.json_serializer = json_serializer or partial(json.dumps, default=self.json_default, separators=(",", ":"))
112+
113+
if datefmt and datetime_fmt:
114+
raise ValueError(f"at most one of datefmt {datefmt!r} and datetime_fmt {datetime_fmt!r} can be specified")
102115
self.datefmt = datefmt
116+
self.datetime_fmt = datetime_fmt
117+
103118
self.utc = utc
104119
self.log_record_order = log_record_order or ["level", "location", "message", "timestamp"]
105120
self.log_format = dict.fromkeys(self.log_record_order) # Set the insertion order for the log messages
@@ -129,13 +144,26 @@ def format(self, record: logging.LogRecord) -> str: # noqa: A003
129144

130145
def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
131146
record_ts = self.converter(record.created) # type: ignore
132-
if datefmt:
133-
return time.strftime(datefmt, record_ts)
134147

135148
# NOTE: Python `time.strftime` doesn't provide msec directives
136149
# so we create a custom one (%F) and replace logging record ts
137150
# Reason 2 is that std logging doesn't support msec after TZ
138151
msecs = "%03d" % record.msecs
152+
153+
if datefmt or self.datefmt:
154+
return time.strftime(datefmt or self.datefmt, record_ts)
155+
156+
elif self.datetime_fmt:
157+
timestamp = record.created + record.msecs / 1000
158+
159+
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
160+
if not self.utc:
161+
# convert back to local time
162+
dt = dt.astimezone()
163+
164+
custom_fmt = self.datetime_fmt.replace(self.custom_ms_time_directive, msecs)
165+
return dt.strftime(custom_fmt)
166+
139167
custom_fmt = self.default_time_format.replace(self.custom_ms_time_directive, msecs)
140168
return time.strftime(custom_fmt, record_ts)
141169

@@ -219,7 +247,7 @@ def _extract_log_keys(self, log_record: logging.LogRecord) -> Dict[str, Any]:
219247
Structured log as dictionary
220248
"""
221249
record_dict = log_record.__dict__.copy()
222-
record_dict["asctime"] = self.formatTime(record=log_record, datefmt=self.datefmt)
250+
record_dict["asctime"] = self.formatTime(record=log_record)
223251
extras = {k: v for k, v in record_dict.items() if k not in RESERVED_LOG_ATTRS}
224252

225253
formatted_log = {}

aws_lambda_powertools/logging/logger.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ class Logger(logging.Logger): # lgtm [py/missing-call-to-init]
8080
Parameters propagated to LambdaPowertoolsFormatter
8181
---------------------------------------------
8282
datefmt: str, optional
83-
String directives (strftime) to format log timestamp, by default it uses RFC 3339.
83+
String directives (strftime) to format log timestamp using `time`, by default it uses RFC
84+
3339. Only one of `datefmt` and `datetime_fmt` should be specified.
85+
datetime_fmt : str, optional
86+
String directives (strftime) to format log timestamp using `datetime`. Only one of `datefmt`
87+
and `datetime_fmt` should be specified.
8488
json_serializer : Callable, optional
8589
function to serialize `obj` to a JSON formatted `str`, by default json.dumps
8690
json_deserializer : Callable, optional

tests/functional/test_logger.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import json
44
import logging
55
import random
6+
import re
67
import string
78
from collections import namedtuple
9+
from datetime import datetime, timezone
810
from typing import Iterable
911

1012
import pytest
@@ -610,3 +612,25 @@ def handler(event, context, my_custom_option=None):
610612

611613
# THEN
612614
handler({}, lambda_context, my_custom_option="blah")
615+
616+
617+
@pytest.mark.parametrize("utc", [False, True])
618+
def test_datetime_fmt(stdout, service_name, utc):
619+
# GIVEN
620+
logger = Logger(
621+
service=service_name,
622+
stream=stdout,
623+
datetime_fmt="custom timestamp: milliseconds=%F microseconds=%f timezone=%z",
624+
utc=utc,
625+
)
626+
627+
# WHEN a log statement happens
628+
logger.info({})
629+
630+
# THEN the timestamp has the appropriate formatting
631+
log = capture_logging_output(stdout)
632+
633+
expected_tz = datetime.now().astimezone(timezone.utc if utc else None).strftime("%z")
634+
assert re.fullmatch(
635+
f"custom timestamp: milliseconds=[0-9]+ microseconds=[0-9]+ timezone={re.escape(expected_tz)}", log["timestamp"]
636+
)

0 commit comments

Comments
 (0)