Skip to content

Commit d304345

Browse files
huonwheitorlessa
andauthored
feat(logger): support use_datetime_directive for timestamps (#920)
Co-authored-by: heitorlessa <[email protected]>
1 parent be15e3c commit d304345

File tree

5 files changed

+82
-11
lines changed

5 files changed

+82
-11
lines changed

aws_lambda_powertools/logging/formatter.py

+40-6
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+
use_datetime_directive: bool = False,
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,30 @@ 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.
9092
91-
See https://docs.python.org/3/library/time.html#time.strftime
93+
See https://docs.python.org/3/library/time.html#time.strftime or
94+
use_datetime_directive: str, optional
95+
Interpret `datefmt` as a format string for `datetime.datetime.strftime`, rather than
96+
`time.strftime` - Only useful when used alongside `datefmt`.
97+
98+
See https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior . This
99+
also supports a custom %F directive for milliseconds.
92100
utc : bool, optional
93101
set logging timestamp to UTC, by default False to continue to use local time as per stdlib
94102
log_record_order : list, optional
95103
set order of log keys when logging, by default ["level", "location", "message", "timestamp"]
96104
kwargs
97105
Key-value to be included in log messages
106+
98107
"""
99108
self.json_deserializer = json_deserializer or json.loads
100109
self.json_default = json_default or str
101110
self.json_serializer = json_serializer or partial(json.dumps, default=self.json_default, separators=(",", ":"))
111+
102112
self.datefmt = datefmt
113+
self.use_datetime_directive = use_datetime_directive
114+
103115
self.utc = utc
104116
self.log_record_order = log_record_order or ["level", "location", "message", "timestamp"]
105117
self.log_format = dict.fromkeys(self.log_record_order) # Set the insertion order for the log messages
@@ -129,13 +141,35 @@ def format(self, record: logging.LogRecord) -> str: # noqa: A003
129141

130142
def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
131143
record_ts = self.converter(record.created) # type: ignore
132-
if datefmt:
133-
return time.strftime(datefmt, record_ts)
144+
145+
if datefmt is None: # pragma: no cover, it'll always be None in std logging, but mypy
146+
datefmt = self.datefmt
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+
# Datetime format codes might be optionally used
154+
# however it only makes a difference if `datefmt` is passed
155+
# since format codes are the same except %f
156+
if self.use_datetime_directive and datefmt:
157+
# record.msecs are microseconds, divide by 1000 and we get milliseconds
158+
timestamp = record.created + record.msecs / 1000
159+
160+
if self.utc:
161+
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
162+
else:
163+
# make sure local timezone is included
164+
dt = datetime.fromtimestamp(timestamp).astimezone()
165+
166+
custom_fmt = datefmt.replace(self.custom_ms_time_directive, msecs)
167+
return dt.strftime(custom_fmt)
168+
169+
elif datefmt:
170+
custom_fmt = datefmt.replace(self.custom_ms_time_directive, msecs)
171+
return time.strftime(custom_fmt, record_ts)
172+
139173
custom_fmt = self.default_time_format.replace(self.custom_ms_time_directive, msecs)
140174
return time.strftime(custom_fmt, record_ts)
141175

@@ -219,7 +253,7 @@ def _extract_log_keys(self, log_record: logging.LogRecord) -> Dict[str, Any]:
219253
Structured log as dictionary
220254
"""
221255
record_dict = log_record.__dict__.copy()
222-
record_dict["asctime"] = self.formatTime(record=log_record, datefmt=self.datefmt)
256+
record_dict["asctime"] = self.formatTime(record=log_record)
223257
extras = {k: v for k, v in record_dict.items() if k not in RESERVED_LOG_ATTRS}
224258

225259
formatted_log = {}

aws_lambda_powertools/logging/logger.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,16 @@ class Logger(logging.Logger): # lgtm [py/missing-call-to-init]
7878
custom logging handler e.g. logging.FileHandler("file.log")
7979
8080
Parameters propagated to LambdaPowertoolsFormatter
81-
---------------------------------------------
81+
--------------------------------------------------
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.
85+
use_datetime_directive: str, optional
86+
Interpret `datefmt` as a format string for `datetime.datetime.strftime`, rather than
87+
`time.strftime`.
88+
89+
See https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior . This
90+
also supports a custom %F directive for milliseconds.
8491
json_serializer : Callable, optional
8592
function to serialize `obj` to a JSON formatted `str`, by default json.dumps
8693
json_deserializer : Callable, optional

docs/core/logger.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ Parameter | Description | Default
656656
**`json_deserializer`** | function to deserialize `str`, `bytes`, `bytearray` containing a JSON document to a Python obj | `json.loads`
657657
**`json_default`** | function to coerce unserializable values, when no custom serializer/deserializer is set | `str`
658658
**`datefmt`** | string directives (strftime) to format log timestamp | `%Y-%m-%d %H:%M:%S,%F%z`, where `%F` is a custom ms directive
659+
**`use_datetime_directive`** | format the `datefmt` timestamps using `datetime`, not `time` (also supports the custom `%F` directive for milliseconds) | `False`
659660
**`utc`** | set logging timestamp to UTC | `False`
660661
**`log_record_order`** | set order of log keys when logging | `["level", "location", "message", "timestamp"]`
661662
**`kwargs`** | key-value to be included in log messages | `None`
@@ -726,13 +727,17 @@ In this case, Logger will register a Logger named `payment`, and a Logger named
726727

727728
#### Overriding Log records
728729

730+
???+ tip
731+
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"}.
732+
733+
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).
734+
729735
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.
730736

731737
Logger allows you to either change the format or suppress the following keys altogether at the initialization: `location`, `timestamp`, `level`, `xray_trace_id`.
732738

733-
=== "lambda_handler.py"
734-
> We honour standard [logging library string formats](https://docs.python.org/3/howto/logging.html#displaying-the-date-time-in-messages){target="_blank"}.
735739

740+
=== "lambda_handler.py"
736741
```python hl_lines="7 10"
737742
from aws_lambda_powertools import Logger
738743

docs/utilities/feature_flags.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ You can use `get_enabled_features` method for scenarios where you need a list of
385385
???+ info "When is this useful?"
386386
You might have a list of features to unlock for premium customers, unlock a specific set of features for admin users, etc.
387387

388-
Feature flags can return any JSON values when `boolean_type` parameter is set to `False`. These can be dictionaries, list, string, integers, etc.
388+
Feature flags can return any JSON values when `boolean_type` parameter is set to `false`. These can be dictionaries, list, string, integers, etc.
389389

390390

391391
=== "app.py"

tests/functional/test_logger.py

+25
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,26 @@ 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_use_datetime(stdout, service_name, utc):
619+
# GIVEN
620+
logger = Logger(
621+
service=service_name,
622+
stream=stdout,
623+
datefmt="custom timestamp: milliseconds=%F microseconds=%f timezone=%z",
624+
use_datetime_directive=True,
625+
utc=utc,
626+
)
627+
628+
# WHEN a log statement happens
629+
logger.info({})
630+
631+
# THEN the timestamp has the appropriate formatting
632+
log = capture_logging_output(stdout)
633+
634+
expected_tz = datetime.now().astimezone(timezone.utc if utc else None).strftime("%z")
635+
assert re.fullmatch(
636+
f"custom timestamp: milliseconds=[0-9]+ microseconds=[0-9]+ timezone={re.escape(expected_tz)}", log["timestamp"]
637+
)

0 commit comments

Comments
 (0)