Repository: aws-powertools/powertools-lambda-python

Issue:
Feature request: Add BaseException.__notes__ to log messages
### Use case

As a developer, I would like PowerTools' Logging to support the [notes](https://peps.python.org/pep-0678/) feature (`BaseException.__notes__`) so my log messages can be enriched with data and code natively available via Python exceptions.

### Solution/User Experience

PowerTools' log messages include a `notes: list[str]` attribute populated by Python's `BaseException.__notes__`.

The common use case is when you want to add context to a potential exception but not explicitly log at the time.

```python
try:
    get_data(customer_id)
except Exception as exc:
    exc.add_note(f"Customer: '{customer_id}'")
    raise
```

Ideally, the log message would then include a `notes` attribute:
```json
{
    "level": "ERROR",
    "location": "update_all_customers:64",
    "message": "Super detailed message here.",
    "timestamp": "2025-04-10 15:00:27,218+0000",
    "service": "CustomerManagement",
    "cold_start": true,
    "function_name": "customer_profile_updater",
    "function_memory_size": "512",
    "function_arn": "arn:aws:lambda:us-west-2:111111111111111:function:customer_profile_updater",
    "function_request_id": "b0635544-37e1-4407-8366-35b0c86222e5",
    "taskName": "Task-1",
    "xray_trace_id": "7812696e-9dc6-4909-b314-3d7c2c93da04",
    "notes": [
        "Encountered errors while updating customer profiles.",
        "Customer: '123456'"
    ]
}
```

### Alternative solutions

```markdown
PowerTools' Logging feature already includes alternate solutions but they're specific to PowerTools. It would be great if PowerTools' Logger leveraged the `BaseException.__notes__` attribute already available.
```

### Acknowledgment

- [x] This feature request meets [Powertools for AWS Lambda (Python) Tenets](https://docs.powertools.aws.dev/lambda/python/latest/#tenets)
- [ ] Should this be considered in other Powertools for AWS Lambda languages? i.e. [Java](https://github.com/aws-powertools/powertools-lambda-java/), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript/), and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet/)

Discussion:
@leandrodamascena: Hi @waffleshop! Thanks for opening this issue! Yes, I think it makes a lot of sense to serialize the notes added when logging an exception or error with `exc_info=True`. 

Notes are supported in Python 3.11+ and Powertools supports 3.9+, so we need to add a condition when serializing the exception to check if the Python version is 3.11+ or just if the `__notes__` field exists in that object.

Would you like to submit a PR to implement this? I'd love to have your contribution here.@github-actions: ### ⚠️COMMENT VISIBILITY WARNING⚠️
This issue is now closed. Please be mindful that future comments are hard for our team to see.

If you need more assistance, please either tag a [team member](https://github.com/aws-powertools/powertools-lambda-python/blob/develop/MAINTAINERS.md#current-maintainers) or open a new issue that references this one.

If you wish to keep having a conversation with other community members under this issue feel free to do so.@github-actions: This is now released under [3.11.0](https://github.com/aws-powertools/powertools-lambda-python/releases/tag/v3.11.0) version!

Solution patch:
diff --git a/aws_lambda_powertools/logging/formatter.py b/aws_lambda_powertools/logging/formatter.py
index a330b043f..4dadcaf1e 100644
--- a/aws_lambda_powertools/logging/formatter.py
+++ b/aws_lambda_powertools/logging/formatter.py
@@ -194,9 +194,10 @@ class LambdaPowertoolsFormatter(BasePowertoolsFormatter):
 
         # 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 @@ class LambdaPowertoolsFormatter(BasePowertoolsFormatter):
 
         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 @@ class LambdaPowertoolsFormatter(BasePowertoolsFormatter):
         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 caf338185..41c52b69d 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 000000000..7c05427b6
--- /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 000000000..f50f12d68
--- /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 \"<input>\", line 2, in <module> 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 05e5c1a1e..20a451029 100644
--- a/examples/logger/src/logging_exceptions.py
+++ b/examples/logger/src/logging_exceptions.py
@@ -3,8 +3,8 @@ import requests
 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 128836f51..40e7e052b 100644
--- a/examples/logger/src/logging_stacktrace.py
+++ b/examples/logger/src/logging_stacktrace.py
@@ -3,7 +3,7 @@ import requests
 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)
 
 


Test patch:
diff --git a/tests/functional/logger/required_dependencies/test_logger.py b/tests/functional/logger/required_dependencies/test_logger.py
index a33c59521..fdbeef26c 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)