Skip to content

Commit c337ef9

Browse files
authored
fix(logger): ensure state is cleared for custom formatters (aws-powertools#1072)
1 parent 0ba2ad7 commit c337ef9

File tree

4 files changed

+113
-18
lines changed

4 files changed

+113
-18
lines changed

aws_lambda_powertools/logging/formatter.py

+8
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ def append_keys(self, **additional_keys):
4545
def remove_keys(self, keys: Iterable[str]):
4646
raise NotImplementedError()
4747

48+
@abstractmethod
49+
def clear_state(self):
50+
"""Removes any previously added logging keys"""
51+
raise NotImplementedError()
52+
4853

4954
class LambdaPowertoolsFormatter(BasePowertoolsFormatter):
5055
"""AWS Lambda Powertools Logging formatter.
@@ -180,6 +185,9 @@ def remove_keys(self, keys: Iterable[str]):
180185
for key in keys:
181186
self.log_format.pop(key, None)
182187

188+
def clear_state(self):
189+
self.log_format = dict.fromkeys(self.log_record_order)
190+
183191
@staticmethod
184192
def _build_default_keys():
185193
return {

aws_lambda_powertools/logging/logger.py

+18-6
Original file line numberDiff line numberDiff line change
@@ -385,14 +385,26 @@ def structure_logs(self, append: bool = False, **keys):
385385
append : bool, optional
386386
append keys provided to logger formatter, by default False
387387
"""
388+
# There are 3 operational modes for this method
389+
## 1. Register a Powertools Formatter for the first time
390+
## 2. Append new keys to the current logger formatter; deprecated in favour of append_keys
391+
## 3. Add new keys and discard existing to the registered formatter
388392

389-
if append:
390-
# Maintenance: Add deprecation warning for major version. Refer to append_keys() when docs are updated
391-
self.append_keys(**keys)
392-
else:
393-
log_keys = {**self._default_log_keys, **keys}
393+
# Mode 1
394+
log_keys = {**self._default_log_keys, **keys}
395+
is_logger_preconfigured = getattr(self._logger, "init", False)
396+
if not is_logger_preconfigured:
394397
formatter = self.logger_formatter or LambdaPowertoolsFormatter(**log_keys) # type: ignore
395-
self.registered_handler.setFormatter(formatter)
398+
return self.registered_handler.setFormatter(formatter)
399+
400+
# Mode 2 (legacy)
401+
if append:
402+
# Maintenance: Add deprecation warning for major version
403+
return self.append_keys(**keys)
404+
405+
# Mode 3
406+
self.registered_formatter.clear_state()
407+
self.registered_formatter.append_keys(**log_keys)
396408

397409
def set_correlation_id(self, value: Optional[str]):
398410
"""Sets the correlation_id in the logging json

docs/core/logger.md

+24-10
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,10 @@ logger.info("Collecting payment")
862862

863863
By default, Logger uses [LambdaPowertoolsFormatter](#lambdapowertoolsformatter) that persists its custom structure between non-cold start invocations. There could be scenarios where the existing feature set isn't sufficient to your formatting needs.
864864

865-
For **minor changes like remapping keys** after all log record processing has completed, you can override `serialize` method from [LambdaPowertoolsFormatter](#lambdapowertoolsformatter):
865+
???+ info
866+
The most common use cases are remapping keys by bringing your existing schema, and redacting sensitive information you know upfront.
867+
868+
For these, you can override the `serialize` method from [LambdaPowertoolsFormatter](#lambdapowertoolsformatter).
866869

867870
=== "custom_formatter.py"
868871

@@ -892,28 +895,39 @@ For **minor changes like remapping keys** after all log record processing has co
892895
}
893896
```
894897

895-
For **replacing the formatter entirely**, you can subclass `BasePowertoolsFormatter`, implement `append_keys` method, and override `format` standard logging method. This ensures the current feature set of Logger like [injecting Lambda context](#capturing-lambda-context-info) and [sampling](#sampling-debug-logs) will continue to work.
898+
The `log` argument is the final log record containing [our standard keys](#standard-structured-keys), optionally [Lambda context keys](#capturing-lambda-context-info), and any custom key you might have added via [append_keys](#append_keys-method) or the [extra parameter](#extra-parameter).
899+
900+
For exceptional cases where you want to completely replace our formatter logic, you can subclass `BasePowertoolsFormatter`.
901+
902+
???+ warning
903+
You will need to implement `append_keys`, `clear_state`, override `format`, and optionally `remove_keys` to keep the same feature set Powertools Logger provides. This also means keeping state of logging keys added.
896904

897-
???+ info
898-
You might need to implement `remove_keys` method if you make use of the feature too.
899905

900906
=== "collect.py"
901907

902-
```python hl_lines="2 4 7 12 16 27"
908+
```python hl_lines="5 7 9-10 13 17 21 24 35"
909+
import logging
910+
from typing import Iterable, List, Optional
911+
903912
from aws_lambda_powertools import Logger
904913
from aws_lambda_powertools.logging.formatter import BasePowertoolsFormatter
905914

906915
class CustomFormatter(BasePowertoolsFormatter):
907-
custom_format = {} # arbitrary dict to hold our structured keys
916+
def __init__(self, log_record_order: Optional[List[str]], *args, **kwargs):
917+
self.log_record_order = log_record_order or ["level", "location", "message", "timestamp"]
918+
self.log_format = dict.fromkeys(self.log_record_order)
919+
super().__init__(*args, **kwargs)
908920

909921
def append_keys(self, **additional_keys):
910922
# also used by `inject_lambda_context` decorator
911-
self.custom_format.update(additional_keys)
923+
self.log_format.update(additional_keys)
912924

913-
# Optional unless you make use of this Logger feature
914925
def remove_keys(self, keys: Iterable[str]):
915926
for key in keys:
916-
self.custom_format.pop(key, None)
927+
self.log_format.pop(key, None)
928+
929+
def clear_state(self):
930+
self.log_format = dict.fromkeys(self.log_record_order)
917931

918932
def format(self, record: logging.LogRecord) -> str: # noqa: A003
919933
"""Format logging record as structured JSON str"""
@@ -922,7 +936,7 @@ For **replacing the formatter entirely**, you can subclass `BasePowertoolsFormat
922936
"event": super().format(record),
923937
"timestamp": self.formatTime(record),
924938
"my_default_key": "test",
925-
**self.custom_format,
939+
**self.log_format,
926940
}
927941
)
928942

tests/functional/test_logger.py

+63-2
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@
55
import random
66
import re
77
import string
8+
from ast import Dict
89
from collections import namedtuple
910
from datetime import datetime, timezone
10-
from typing import Iterable
11+
from typing import Any, Callable, Iterable, List, Optional, Union
1112

1213
import pytest
1314

1415
from aws_lambda_powertools import Logger, Tracer
1516
from aws_lambda_powertools.logging import correlation_paths
1617
from aws_lambda_powertools.logging.exceptions import InvalidLoggerSamplingRateError
17-
from aws_lambda_powertools.logging.formatter import BasePowertoolsFormatter
18+
from aws_lambda_powertools.logging.formatter import BasePowertoolsFormatter, LambdaPowertoolsFormatter
1819
from aws_lambda_powertools.logging.logger import set_package_logger
1920
from aws_lambda_powertools.shared import constants
2021
from aws_lambda_powertools.utilities.data_classes import S3Event, event_source
@@ -524,6 +525,9 @@ def remove_keys(self, keys: Iterable[str]):
524525
for key in keys:
525526
self.custom_format.pop(key, None)
526527

528+
def clear_state(self):
529+
self.custom_format.clear()
530+
527531
def format(self, record: logging.LogRecord) -> str: # noqa: A003
528532
return json.dumps(
529533
{
@@ -564,6 +568,63 @@ def handler(event, context):
564568
assert logger.get_correlation_id() is None
565569

566570

571+
def test_logger_custom_powertools_formatter_clear_state(stdout, service_name, lambda_context):
572+
class CustomFormatter(LambdaPowertoolsFormatter):
573+
def __init__(
574+
self,
575+
json_serializer: Optional[Callable[[Dict], str]] = None,
576+
json_deserializer: Optional[Callable[[Union[Dict, str, bool, int, float]], str]] = None,
577+
json_default: Optional[Callable[[Any], Any]] = None,
578+
datefmt: Optional[str] = None,
579+
use_datetime_directive: bool = False,
580+
log_record_order: Optional[List[str]] = None,
581+
utc: bool = False,
582+
**kwargs,
583+
):
584+
super().__init__(
585+
json_serializer,
586+
json_deserializer,
587+
json_default,
588+
datefmt,
589+
use_datetime_directive,
590+
log_record_order,
591+
utc,
592+
**kwargs,
593+
)
594+
595+
custom_formatter = CustomFormatter()
596+
597+
# GIVEN a Logger is initialized with a custom formatter
598+
logger = Logger(service=service_name, stream=stdout, logger_formatter=custom_formatter)
599+
600+
# WHEN a lambda function is decorated with logger
601+
# and state is to be cleared in the next invocation
602+
@logger.inject_lambda_context(clear_state=True)
603+
def handler(event, context):
604+
if event.get("add_key"):
605+
logger.append_keys(my_key="value")
606+
logger.info("Hello")
607+
608+
handler({"add_key": True}, lambda_context)
609+
handler({}, lambda_context)
610+
611+
lambda_context_keys = (
612+
"function_name",
613+
"function_memory_size",
614+
"function_arn",
615+
"function_request_id",
616+
)
617+
618+
first_log, second_log = capture_multiple_logging_statements_output(stdout)
619+
620+
# THEN my_key should only present once
621+
# and lambda contextual info should also be in both logs
622+
assert "my_key" in first_log
623+
assert "my_key" not in second_log
624+
assert all(k in first_log for k in lambda_context_keys)
625+
assert all(k in second_log for k in lambda_context_keys)
626+
627+
567628
def test_logger_custom_handler(lambda_context, service_name, tmp_path):
568629
# GIVEN a Logger is initialized with a FileHandler
569630
log_file = tmp_path / "log.json"

0 commit comments

Comments
 (0)