Skip to content

Commit e96608e

Browse files
feat(logger): add context manager for logger keys (#5883)
* add context manager to logger * Passing the method implementation to the formatter class * modify logger tests * add examples to doc --------- Co-authored-by: Leandro Damascena <[email protected]>
1 parent d82537c commit e96608e

File tree

6 files changed

+206
-11
lines changed

6 files changed

+206
-11
lines changed

aws_lambda_powertools/logging/formatter.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
import time
88
import traceback
99
from abc import ABCMeta, abstractmethod
10+
from contextlib import contextmanager
1011
from contextvars import ContextVar
1112
from datetime import datetime, timezone
1213
from functools import partial
13-
from typing import TYPE_CHECKING, Any, Callable, Iterable
14+
from typing import TYPE_CHECKING, Any, Callable, Generator, Iterable
1415

1516
from aws_lambda_powertools.shared import constants
1617
from aws_lambda_powertools.shared.functions import powertools_dev_is_set
@@ -62,6 +63,10 @@ def clear_state(self) -> None:
6263
"""Removes any previously added logging keys"""
6364
raise NotImplementedError()
6465

66+
@contextmanager
67+
def append_context_keys(self, **additional_keys: Any) -> Generator[None, None, None]:
68+
yield
69+
6570
# These specific thread-safe methods are necessary to manage shared context in concurrent environments.
6671
# They prevent race conditions and ensure data consistency across multiple threads.
6772
def thread_safe_append_keys(self, **additional_keys) -> None:
@@ -263,6 +268,31 @@ def clear_state(self) -> None:
263268
self.log_format = dict.fromkeys(self.log_record_order)
264269
self.log_format.update(**self.keys_combined)
265270

271+
@contextmanager
272+
def append_context_keys(self, **additional_keys: Any) -> Generator[None, None, None]:
273+
"""
274+
Context manager to temporarily add logging keys.
275+
276+
Parameters:
277+
-----------
278+
**keys: Any
279+
Key-value pairs to include in the log context during the lifespan of the context manager.
280+
281+
Example:
282+
--------
283+
>>> logger = Logger(service="example_service")
284+
>>> with logger.append_context_keys(user_id="123", operation="process"):
285+
>>> logger.info("Log with context")
286+
>>> logger.info("Log without context")
287+
"""
288+
# Add keys to the context
289+
self.append_keys(**additional_keys)
290+
try:
291+
yield
292+
finally:
293+
# Remove the keys after exiting the context
294+
self.remove_keys(additional_keys.keys())
295+
266296
# These specific thread-safe methods are necessary to manage shared context in concurrent environments.
267297
# They prevent race conditions and ensure data consistency across multiple threads.
268298
def thread_safe_append_keys(self, **additional_keys) -> None:

aws_lambda_powertools/logging/logger.py

+22-10
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,8 @@
77
import random
88
import sys
99
import warnings
10-
from typing import (
11-
IO,
12-
TYPE_CHECKING,
13-
Any,
14-
Callable,
15-
Iterable,
16-
Mapping,
17-
TypeVar,
18-
overload,
19-
)
10+
from contextlib import contextmanager
11+
from typing import IO, TYPE_CHECKING, Any, Callable, Generator, Iterable, Mapping, TypeVar, overload
2012

2113
from aws_lambda_powertools.logging.constants import (
2214
LOGGER_ATTRIBUTE_PRECONFIGURED,
@@ -589,6 +581,26 @@ def get_current_keys(self) -> dict[str, Any]:
589581
def remove_keys(self, keys: Iterable[str]) -> None:
590582
self.registered_formatter.remove_keys(keys)
591583

584+
@contextmanager
585+
def append_context_keys(self, **additional_keys: Any) -> Generator[None, None, None]:
586+
"""
587+
Context manager to temporarily add logging keys.
588+
589+
Parameters:
590+
-----------
591+
**keys: Any
592+
Key-value pairs to include in the log context during the lifespan of the context manager.
593+
594+
Example:
595+
--------
596+
>>> logger = Logger(service="example_service")
597+
>>> with logger.append_context_keys(user_id="123", operation="process"):
598+
>>> logger.info("Log with context")
599+
>>> logger.info("Log without context")
600+
"""
601+
with self.registered_formatter.append_context_keys(**additional_keys):
602+
yield
603+
592604
# These specific thread-safe methods are necessary to manage shared context in concurrent environments.
593605
# They prevent race conditions and ensure data consistency across multiple threads.
594606
def thread_safe_append_keys(self, **additional_keys: object) -> None:

docs/core/logger.md

+19
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,25 @@ You can append your own keys to your existing Logger via `append_keys(**addition
187187

188188
This example will add `order_id` if its value is not empty, and in subsequent invocations where `order_id` might not be present it'll remove it from the Logger.
189189

190+
#### append_context_keys method
191+
192+
???+ warning
193+
`append_context_keys` is not thread-safe.
194+
195+
The append_context_keys method allows temporary modification of a Logger instance's context without creating a new logger. It's useful for adding context keys to specific workflows while maintaining the logger's overall state and simplicity.
196+
197+
=== "append_context_keys.py"
198+
199+
```python hl_lines="7 8"
200+
--8<-- "examples/logger/src/append_context_keys.py"
201+
```
202+
203+
=== "append_context_keys_output.json"
204+
205+
```json hl_lines="8 9"
206+
--8<-- "examples/logger/src/append_context_keys.json"
207+
```
208+
190209
#### ephemeral metadata
191210

192211
You can pass an arbitrary number of keyword arguments (kwargs) to all log level's methods, e.g. `logger.info, logger.warning`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"level": "INFO",
4+
"location": "lambda_handler:8",
5+
"message": "Log with context",
6+
"timestamp": "2024-03-21T10:30:00.123Z",
7+
"service": "example_service",
8+
"user_id": "123",
9+
"operation": "process"
10+
},
11+
{
12+
"level": "INFO",
13+
"location": "lambda_handler:10",
14+
"message": "Log without context",
15+
"timestamp": "2024-03-21T10:30:00.124Z",
16+
"service": "example_service"
17+
}
18+
]
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from aws_lambda_powertools import Logger
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
logger = Logger(service="example_service")
5+
6+
7+
def lambda_handler(event: dict, context: LambdaContext) -> str:
8+
with logger.append_context_keys(user_id="123", operation="process"):
9+
logger.info("Log with context")
10+
11+
logger.info("Log without context")
12+
13+
return "hello world"

tests/functional/logger/required_dependencies/test_logger.py

+103
Original file line numberDiff line numberDiff line change
@@ -1114,3 +1114,106 @@ def test_logger_json_unicode(stdout, service_name):
11141114

11151115
assert log["message"] == non_ascii_chars
11161116
assert log[japanese_field] == japanese_string
1117+
1118+
1119+
def test_append_context_keys_adds_and_removes_keys(stdout, service_name):
1120+
# GIVEN a Logger is initialized
1121+
logger = Logger(service=service_name, stream=stdout)
1122+
test_keys = {"user_id": "123", "operation": "test"}
1123+
1124+
# WHEN context keys are added
1125+
with logger.append_context_keys(**test_keys):
1126+
logger.info("message with context keys")
1127+
logger.info("message without context keys")
1128+
1129+
# THEN context keys should only be present in the first log statement
1130+
with_context_log, without_context_log = capture_multiple_logging_statements_output(stdout)
1131+
1132+
assert "user_id" in with_context_log
1133+
assert test_keys["user_id"] == with_context_log["user_id"]
1134+
assert "user_id" not in without_context_log
1135+
1136+
1137+
def test_append_context_keys_handles_empty_dict(stdout, service_name):
1138+
# GIVEN a Logger is initialized
1139+
logger = Logger(service=service_name, stream=stdout)
1140+
1141+
# WHEN context is added with no keys
1142+
with logger.append_context_keys():
1143+
logger.info("message with empty context")
1144+
1145+
# THEN log should contain only default keys
1146+
log_output = capture_logging_output(stdout)
1147+
assert set(log_output.keys()) == {"service", "timestamp", "level", "message", "location"}
1148+
1149+
1150+
def test_append_context_keys_handles_exception(stdout, service_name):
1151+
# GIVEN a Logger is initialized
1152+
logger = Logger(service=service_name, stream=stdout)
1153+
test_user_id = "128"
1154+
1155+
# WHEN an exception occurs within the context
1156+
exception_raised = False
1157+
try:
1158+
with logger.append_context_keys(user_id=test_user_id):
1159+
logger.info("message before exception")
1160+
raise ValueError("Test exception")
1161+
except ValueError:
1162+
exception_raised = True
1163+
logger.info("message after exception")
1164+
1165+
# THEN verify the exception was raised and handled
1166+
assert exception_raised, "Expected ValueError to be raised"
1167+
1168+
1169+
def test_append_context_keys_nested_contexts(stdout, service_name):
1170+
# GIVEN a Logger is initialized
1171+
logger = Logger(service=service_name, stream=stdout)
1172+
1173+
# WHEN nested contexts are used
1174+
with logger.append_context_keys(level1="outer"):
1175+
logger.info("outer context message")
1176+
with logger.append_context_keys(level2="inner"):
1177+
logger.info("nested context message")
1178+
logger.info("back to outer context message")
1179+
logger.info("no context message")
1180+
1181+
# THEN logs should contain appropriate context keys
1182+
outer, nested, back_outer, no_context = capture_multiple_logging_statements_output(stdout)
1183+
1184+
assert outer["level1"] == "outer"
1185+
assert "level2" not in outer
1186+
1187+
assert nested["level1"] == "outer"
1188+
assert nested["level2"] == "inner"
1189+
1190+
assert back_outer["level1"] == "outer"
1191+
assert "level2" not in back_outer
1192+
1193+
assert "level1" not in no_context
1194+
assert "level2" not in no_context
1195+
1196+
1197+
def test_append_context_keys_with_formatter(stdout, service_name):
1198+
# GIVEN a Logger is initialized with a custom formatter
1199+
class CustomFormatter(BasePowertoolsFormatter):
1200+
def append_keys(self, **additional_keys):
1201+
pass
1202+
1203+
def clear_state(self) -> None:
1204+
pass
1205+
1206+
def remove_keys(self, keys: Iterable[str]) -> None:
1207+
pass
1208+
1209+
custom_formatter = CustomFormatter()
1210+
logger = Logger(service=service_name, stream=stdout, logger_formatter=custom_formatter)
1211+
test_keys = {"request_id": "id", "context": "value"}
1212+
1213+
# WHEN context keys are added
1214+
with logger.append_context_keys(**test_keys):
1215+
logger.info("message with context")
1216+
1217+
# THEN the context keys should not persist
1218+
current_keys = logger.get_current_keys()
1219+
assert current_keys == {}

0 commit comments

Comments
 (0)