Skip to content

Commit 0474a93

Browse files
committed
feat: adding AWS_LAMBDA_LOG_TIMESTAMP_PRECISION env
This is done to support millisecond precision timestamp in the structured logs in cloudwatch as requested in #156. Changing directly to millisecond is a potentially breaking change, so we agreed on supporting this environmental variable on this version of the RIC. We'll remove the support in RIC v5, defaulting to milisecond.
1 parent 4cddb14 commit 0474a93

3 files changed

Lines changed: 154 additions & 16 deletions

File tree

awslambdaric/bootstrap.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
from .lambda_runtime_client import LambdaRuntimeClient
1515
from .lambda_runtime_exception import FaultException
1616
from .lambda_runtime_log_utils import (
17-
_DATETIME_FORMAT,
1817
_DEFAULT_FRAME_TYPE,
1918
_JSON_FRAME_TYPES,
2019
_TEXT_FRAME_TYPES,
20+
_format_timestamp,
2121
JsonFormatter,
2222
LogFormat,
2323
_format_log_level,
@@ -106,9 +106,7 @@ def replace_line_indentation(line, indent_char, new_indent_char):
106106

107107
def log_error(error_result, log_sink):
108108
error_result = {
109-
"timestamp": time.strftime(
110-
_DATETIME_FORMAT, logging.Formatter.converter(time.time())
111-
),
109+
"timestamp": _format_timestamp(time.time()),
112110
"log_level": "ERROR",
113111
**error_result,
114112
}

awslambdaric/lambda_runtime_log_utils.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,34 @@
55

66
import json
77
import logging
8+
import os
89
import traceback
10+
from datetime import datetime, timezone
911
from enum import IntEnum
1012

11-
_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
13+
# TODO(v5.0): Remove this env var check and make milliseconds the default.
14+
# Once removed, _format_timestamp simplifies to just the millis branch.
15+
_TIMESTAMP_PRECISION_MILLIS = (
16+
os.environ.get("AWS_LAMBDA_LOG_TIMESTAMP_PRECISION", "").lower() == "milliseconds"
17+
)
18+
19+
20+
def _format_timestamp(epoch_secs):
21+
"""Format a UTC timestamp in ISO 8601 format.
22+
23+
Returns second precision by default (e.g. 2024-06-19T23:13:05Z), to avoid breaking changes.
24+
When AWS_LAMBDA_LOG_TIMESTAMP_PRECISION=milliseconds, includes
25+
milliseconds (e.g. 2024-06-19T23:13:05.068Z).
26+
"""
27+
dt = datetime.fromtimestamp(epoch_secs, tz=timezone.utc)
28+
29+
if _TIMESTAMP_PRECISION_MILLIS:
30+
millis = dt.microsecond // 1000
31+
return f"{dt:%Y-%m-%dT%H:%M:%S}.{millis:03d}Z"
32+
33+
return f"{dt:%Y-%m-%dT%H:%M:%S}Z"
34+
35+
1236
_RESERVED_FIELDS = {
1337
"name",
1438
"msg",
@@ -79,7 +103,10 @@ def _format_log_level(record: logging.LogRecord) -> int:
79103

80104
class JsonFormatter(logging.Formatter):
81105
def __init__(self):
82-
super().__init__(datefmt=_DATETIME_FORMAT)
106+
super().__init__()
107+
108+
def formatTime(self, record, datefmt=None):
109+
return _format_timestamp(record.created)
83110

84111
@staticmethod
85112
def __format_stacktrace(exc_info):

tests/test_lambda_runtime_log_utils.py

Lines changed: 123 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@
33
SPDX-License-Identifier: Apache-2.0
44
"""
55

6+
import importlib
67
import logging
78
import json
8-
import re
9+
import os
910
import time
1011
import unittest
12+
from unittest.mock import patch
1113

12-
from awslambdaric.lambda_runtime_log_utils import JsonFormatter
1314

14-
15-
class TestJsonFormatterTimestamp(unittest.TestCase):
15+
class TestJsonFormatterTimestampDefaultPrecision(unittest.TestCase):
1616
def setUp(self):
17-
self.formatter = JsonFormatter()
18-
self.logger = logging.getLogger("test")
19-
self.logger.setLevel(logging.INFO)
17+
env = os.environ.copy()
18+
env.pop("AWS_LAMBDA_LOG_TIMESTAMP_PRECISION", None)
19+
with patch.dict(os.environ, env, clear=True):
20+
import awslambdaric.lambda_runtime_log_utils as mod
21+
22+
importlib.reload(mod)
23+
self.formatter = mod.JsonFormatter()
2024

2125
def test_timestamp_format_is_second_precision_with_z(self):
2226
record = logging.LogRecord(
@@ -53,9 +57,7 @@ def test_timestamp_value_is_accurate(self):
5357
output = self.formatter.format(record)
5458
log_entry = json.loads(output)
5559

56-
expected = time.strftime(
57-
"%Y-%m-%dT%H:%M:%SZ", self.formatter.converter(record.created)
58-
)
60+
expected = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created))
5961
self.assertEqual(log_entry["timestamp"], expected)
6062

6163
def test_timestamp_does_not_include_milliseconds(self):
@@ -102,3 +104,114 @@ def test_timestamps_same_within_same_second(self):
102104
output2 = json.loads(self.formatter.format(record2))
103105

104106
self.assertEqual(output1["timestamp"], output2["timestamp"])
107+
108+
109+
class TestJsonFormatterTimestampMillisecondPrecision(unittest.TestCase):
110+
def setUp(self):
111+
env = os.environ.copy()
112+
env["AWS_LAMBDA_LOG_TIMESTAMP_PRECISION"] = "milliseconds"
113+
with patch.dict(os.environ, env, clear=True):
114+
import awslambdaric.lambda_runtime_log_utils as mod
115+
116+
importlib.reload(mod)
117+
self.formatter = mod.JsonFormatter()
118+
119+
def test_timestamp_includes_milliseconds(self):
120+
record = logging.LogRecord(
121+
name="test",
122+
level=logging.INFO,
123+
pathname="test.py",
124+
lineno=1,
125+
msg="hello",
126+
args=None,
127+
exc_info=None,
128+
)
129+
output = self.formatter.format(record)
130+
log_entry = json.loads(output)
131+
timestamp = log_entry["timestamp"]
132+
133+
pattern = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$"
134+
self.assertRegex(
135+
timestamp,
136+
pattern,
137+
f"Timestamp '{timestamp}' does not match expected format YYYY-MM-DDTHH:MM:SS.mmmZ",
138+
)
139+
140+
def test_timestamp_milliseconds_are_accurate(self):
141+
record = logging.LogRecord(
142+
name="test",
143+
level=logging.INFO,
144+
pathname="test.py",
145+
lineno=1,
146+
msg="hello",
147+
args=None,
148+
exc_info=None,
149+
)
150+
record.created = 1718838785.068
151+
output = self.formatter.format(record)
152+
log_entry = json.loads(output)
153+
154+
self.assertEqual(log_entry["timestamp"], "2024-06-19T23:13:05.068Z")
155+
156+
def test_timestamp_zero_milliseconds(self):
157+
record = logging.LogRecord(
158+
name="test",
159+
level=logging.INFO,
160+
pathname="test.py",
161+
lineno=1,
162+
msg="hello",
163+
args=None,
164+
exc_info=None,
165+
)
166+
record.created = 1718838785.0
167+
output = self.formatter.format(record)
168+
log_entry = json.loads(output)
169+
170+
self.assertEqual(log_entry["timestamp"], "2024-06-19T23:13:05.000Z")
171+
172+
def test_timestamps_differ_within_same_second(self):
173+
record1 = logging.LogRecord(
174+
name="test",
175+
level=logging.INFO,
176+
pathname="test.py",
177+
lineno=1,
178+
msg="first",
179+
args=None,
180+
exc_info=None,
181+
)
182+
record1.created = 1718838785.100
183+
184+
record2 = logging.LogRecord(
185+
name="test",
186+
level=logging.INFO,
187+
pathname="test.py",
188+
lineno=1,
189+
msg="second",
190+
args=None,
191+
exc_info=None,
192+
)
193+
record2.created = 1718838785.200
194+
195+
output1 = json.loads(self.formatter.format(record1))
196+
output2 = json.loads(self.formatter.format(record2))
197+
198+
self.assertNotEqual(output1["timestamp"], output2["timestamp"])
199+
self.assertEqual(output1["timestamp"], "2024-06-19T23:13:05.100Z")
200+
self.assertEqual(output2["timestamp"], "2024-06-19T23:13:05.200Z")
201+
202+
203+
class TestTimestampPrecisionVersionGate(unittest.TestCase):
204+
"""Fails if we bump to v5+ without removing the seconds-precision path."""
205+
206+
def test_v5_must_remove_timestamp_precision_env_var(self):
207+
from awslambdaric import __version__
208+
209+
major = int(__version__.split(".")[0])
210+
if major >= 5:
211+
import awslambdaric.lambda_runtime_log_utils as mod
212+
213+
self.assertFalse(
214+
hasattr(mod, "_TIMESTAMP_PRECISION_MILLIS"),
215+
"v5+: remove _TIMESTAMP_PRECISION_MILLIS and make milliseconds "
216+
"the default. See TODO(v5.0) in lambda_runtime_log_utils.py",
217+
)

0 commit comments

Comments
 (0)