diff --git a/aws_lambda_powertools/logging/formatter.py b/aws_lambda_powertools/logging/formatter.py index a330b043f75..4dadcaf1ef7 100644 --- a/aws_lambda_powertools/logging/formatter.py +++ b/aws_lambda_powertools/logging/formatter.py @@ -194,9 +194,10 @@ def format(self, record: logging.LogRecord) -> str: # noqa: A003 # exception and exception_name fields can be added as extra key # in any log level, we try to extract and use them first - extracted_exception, extracted_exception_name = self._extract_log_exception(log_record=record) + extracted_exception, extracted_exception_name, exception_notes = self._extract_log_exception(log_record=record) formatted_log["exception"] = formatted_log.get("exception", extracted_exception) formatted_log["exception_name"] = formatted_log.get("exception_name", extracted_exception_name) + formatted_log["exception_notes"] = formatted_log.get("exception_notes", exception_notes) if self.serialize_stacktrace: # Generate the traceback from the traceback library formatted_log["stack_trace"] = self._serialize_stacktrace(log_record=record) @@ -380,7 +381,7 @@ def _serialize_stacktrace(self, log_record: logging.LogRecord) -> LogStackTrace return None - def _extract_log_exception(self, log_record: logging.LogRecord) -> tuple[str, str] | tuple[None, None]: + def _extract_log_exception(self, log_record: logging.LogRecord) -> tuple[str, str, list] | tuple[None, None, None]: """Format traceback information, if available Parameters @@ -393,10 +394,12 @@ def _extract_log_exception(self, log_record: logging.LogRecord) -> tuple[str, st log_record: tuple[str, str] | tuple[None, None] Log record with constant traceback info and exception name """ + if isinstance(log_record.exc_info, tuple) and hasattr(log_record.exc_info[0], "__name__"): - return self.formatException(log_record.exc_info), log_record.exc_info[0].__name__ # type: ignore + exception_notes = getattr(log_record.exc_info[1], "__notes__", None) + return self.formatException(log_record.exc_info), log_record.exc_info[0].__name__, exception_notes # type: ignore - return None, None + return None, None, None def _extract_log_keys(self, log_record: logging.LogRecord) -> dict[str, Any]: """Extract and parse custom and reserved log keys diff --git a/docs/core/logger.md b/docs/core/logger.md index caf3381853b..41c52b69db4 100644 --- a/docs/core/logger.md +++ b/docs/core/logger.md @@ -476,6 +476,22 @@ By default, the Logger will automatically include the full stack trace in JSON f --8<-- "examples/logger/src/logging_stacktrace_output.json" ``` +#### Adding exception notes + +You can add notes to exceptions, which `logger.exception` propagates via a new `exception_notes` key in the log line. This works only in [Python 3.11 and later](https://peps.python.org/pep-0678/){target="_blank" rel="nofollow"}. + +=== "logging_exception_notes.py" + + ```python hl_lines="15" + --8<-- "examples/logger/src/logging_exception_notes.py" + ``` + +=== "logging_exception_notes_output.json" + + ```json hl_lines="9-11" + --8<-- "examples/logger/src/logging_exception_notes_output.json" + ``` + ### Date formatting Logger uses Python's standard logging date format with the addition of timezone: `2021-05-03 11:47:12,494+0000`. diff --git a/examples/logger/src/logging_exception_notes.py b/examples/logger/src/logging_exception_notes.py new file mode 100644 index 00000000000..7c05427b6e6 --- /dev/null +++ b/examples/logger/src/logging_exception_notes.py @@ -0,0 +1,19 @@ +import requests + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext + +ENDPOINT = "https://httpbin.org/status/500" +logger = Logger(serialize_stacktrace=False) + + +def lambda_handler(event: dict, context: LambdaContext) -> str: + try: + ret = requests.get(ENDPOINT) + ret.raise_for_status() + except requests.HTTPError as e: + e.add_note("Can't connect to the endpoint") # type: ignore[attr-defined] + logger.exception(e) + raise RuntimeError("Unable to fullfil request") from e + + return "hello world" diff --git a/examples/logger/src/logging_exception_notes_output.json b/examples/logger/src/logging_exception_notes_output.json new file mode 100644 index 00000000000..f50f12d689a --- /dev/null +++ b/examples/logger/src/logging_exception_notes_output.json @@ -0,0 +1,12 @@ +{ + "level": "ERROR", + "location": "collect.handler:15", + "message": "Received a HTTP 5xx error", + "timestamp": "2021-05-03 11:47:12,494+0000", + "service": "payment", + "exception_name": "RuntimeError", + "exception": "Traceback (most recent call last):\n File \"\", line 2, in RuntimeError: Unable to fullfil request", + "exception_notes":[ + "Can't connect to the endpoint" + ] +} diff --git a/examples/logger/src/logging_exceptions.py b/examples/logger/src/logging_exceptions.py index 05e5c1a1e15..20a45102992 100644 --- a/examples/logger/src/logging_exceptions.py +++ b/examples/logger/src/logging_exceptions.py @@ -3,8 +3,8 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.typing import LambdaContext -ENDPOINT = "http://httpbin.org/status/500" -logger = Logger() +ENDPOINT = "https://httpbin.org/status/500" +logger = Logger(serialize_stacktrace=False) def lambda_handler(event: dict, context: LambdaContext) -> str: diff --git a/examples/logger/src/logging_stacktrace.py b/examples/logger/src/logging_stacktrace.py index 128836f5138..40e7e052be8 100644 --- a/examples/logger/src/logging_stacktrace.py +++ b/examples/logger/src/logging_stacktrace.py @@ -3,7 +3,7 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.typing import LambdaContext -ENDPOINT = "http://httpbin.org/status/500" +ENDPOINT = "https://httpbin.org/status/500" logger = Logger(serialize_stacktrace=True) diff --git a/tests/functional/logger/required_dependencies/test_logger.py b/tests/functional/logger/required_dependencies/test_logger.py index a33c59521ed..fdbeef26c64 100644 --- a/tests/functional/logger/required_dependencies/test_logger.py +++ b/tests/functional/logger/required_dependencies/test_logger.py @@ -638,6 +638,28 @@ def test_logger_exception_extract_exception_name(stdout, service_name): assert "ValueError" == log["exception_name"] +@pytest.mark.skipif(sys.version_info < (3, 11), reason="This only works in Python 3.11+") +def test_logger_exception_extract_exception_notes(stdout, service_name): + # GIVEN Logger is initialized + logger = Logger(service=service_name, stream=stdout) + + # WHEN calling a logger.exception with a ValueError and notes + try: + raise ValueError("something went wrong") + except Exception as error: + error.add_note("something went wrong") + error.add_note("something went wrong again") + logger.exception("Received an exception") + + # THEN we expect a "exception_name" to be "ValueError" + # THEN we except to have exception_notes in the exception + log = capture_logging_output(stdout) + assert len(log["exception_notes"]) == 2 + assert log["exception_notes"][0] == "something went wrong" + assert log["exception_notes"][1] == "something went wrong again" + assert "ValueError" == log["exception_name"] + + def test_logger_exception_should_not_fail_with_exception_block(stdout, service_name): # GIVEN Logger is initialized logger = Logger(service=service_name, stream=stdout)