Skip to content

Commit d4a6726

Browse files
authored
feat(logger): log uncaught exceptions via system's exception hook (#1727)
1 parent 2b4740a commit d4a6726

File tree

5 files changed

+82
-0
lines changed

5 files changed

+82
-0
lines changed

Diff for: aws_lambda_powertools/logging/logger.py

+19
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import functools
24
import inspect
35
import io
@@ -96,6 +98,11 @@ class Logger(logging.Logger): # lgtm [py/missing-call-to-init]
9698
custom logging formatter that implements PowertoolsFormatter
9799
logger_handler: logging.Handler, optional
98100
custom logging handler e.g. logging.FileHandler("file.log")
101+
log_uncaught_exceptions: bool, by default False
102+
logs uncaught exception using sys.excepthook
103+
104+
See: https://docs.python.org/3/library/sys.html#sys.excepthook
105+
99106
100107
Parameters propagated to LambdaPowertoolsFormatter
101108
--------------------------------------------------
@@ -203,6 +210,7 @@ def __init__(
203210
stream: Optional[IO[str]] = None,
204211
logger_formatter: Optional[PowertoolsFormatter] = None,
205212
logger_handler: Optional[logging.Handler] = None,
213+
log_uncaught_exceptions: bool = False,
206214
json_serializer: Optional[Callable[[Dict], str]] = None,
207215
json_deserializer: Optional[Callable[[Union[Dict, str, bool, int, float]], str]] = None,
208216
json_default: Optional[Callable[[Any], Any]] = None,
@@ -222,6 +230,8 @@ def __init__(
222230
self.child = child
223231
self.logger_formatter = logger_formatter
224232
self.logger_handler = logger_handler or logging.StreamHandler(stream)
233+
self.log_uncaught_exceptions = log_uncaught_exceptions
234+
225235
self.log_level = self._get_log_level(level)
226236
self._is_deduplication_disabled = resolve_truthy_env_var_choice(
227237
env=os.getenv(constants.LOGGER_LOG_DEDUPLICATION_ENV, "false")
@@ -244,6 +254,10 @@ def __init__(
244254

245255
self._init_logger(formatter_options=formatter_options, **kwargs)
246256

257+
if self.log_uncaught_exceptions:
258+
logger.debug("Replacing exception hook")
259+
sys.excepthook = functools.partial(log_uncaught_exception_hook, logger=self)
260+
247261
# Prevent __getattr__ from shielding unknown attribute errors in type checkers
248262
# https://github.com/awslabs/aws-lambda-powertools-python/issues/1660
249263
if not TYPE_CHECKING:
@@ -735,3 +749,8 @@ def _is_internal_frame(frame): # pragma: no cover
735749
"""Signal whether the frame is a CPython or logging module internal."""
736750
filename = os.path.normcase(frame.f_code.co_filename)
737751
return filename == logging._srcfile or ("importlib" in filename and "_bootstrap" in filename)
752+
753+
754+
def log_uncaught_exception_hook(exc_type, exc_value, exc_traceback, logger: Logger):
755+
"""Callback function for sys.excepthook to use Logger to log uncaught exceptions"""
756+
logger.exception("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) # pragma: no cover

Diff for: docs/core/logger.md

+24
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,30 @@ Use `logger.exception` method to log contextual information about exceptions. Lo
291291
--8<-- "examples/logger/src/logging_exceptions_output.json"
292292
```
293293

294+
#### Uncaught exceptions
295+
296+
Logger can optionally log uncaught exceptions by setting `log_uncaught_exceptions=True` at initialization.
297+
298+
!!! info "Logger will replace any exception hook previously registered via [sys.excepthook](https://docs.python.org/3/library/sys.html#sys.excepthook){target='_blank'}."
299+
300+
??? question "What are uncaught exceptions?"
301+
302+
It's any raised exception that wasn't handled by the [`except` statement](https://docs.python.org/3.9/tutorial/errors.html#handling-exceptions){target="_blank"}, leading a Python program to a non-successful exit.
303+
304+
They are typically raised intentionally to signal a problem (`raise ValueError`), or a propagated exception from elsewhere in your code that you didn't handle it willingly or not (`KeyError`, `jsonDecoderError`, etc.).
305+
306+
=== "logging_uncaught_exceptions.py"
307+
308+
```python hl_lines="7"
309+
--8<-- "examples/logger/src/logging_uncaught_exceptions.py"
310+
```
311+
312+
=== "logging_uncaught_exceptions_output.json"
313+
314+
```json hl_lines="7-8"
315+
--8<-- "examples/logger/src/logging_uncaught_exceptions_output.json"
316+
```
317+
294318
### Date formatting
295319

296320
Logger uses Python's standard logging date format with the addition of timezone: `2021-05-03 11:47:12,494+0200`.

Diff for: examples/logger/src/logging_uncaught_exceptions.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import requests
2+
3+
from aws_lambda_powertools import Logger
4+
from aws_lambda_powertools.utilities.typing import LambdaContext
5+
6+
ENDPOINT = "http://httpbin.org/status/500"
7+
logger = Logger(log_uncaught_exceptions=True)
8+
9+
10+
def handler(event: dict, context: LambdaContext) -> str:
11+
ret = requests.get(ENDPOINT)
12+
# HTTP 4xx/5xx status will lead to requests.HTTPError
13+
# Logger will log this exception before this program exits non-successfully
14+
ret.raise_for_status()
15+
16+
return "hello world"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"level": "ERROR",
3+
"location": "log_uncaught_exception_hook:756",
4+
"message": "Uncaught exception",
5+
"timestamp": "2022-11-16 13:51:29,198+0100",
6+
"service": "payment",
7+
"exception": "Traceback (most recent call last):\n File \"<input>\", line 52, in <module>\n handler({}, {})\n File \"<input>\", line 17, in handler\n ret.raise_for_status()\n File \"<input>/lib/python3.9/site-packages/requests/models.py\", line 1021, in raise_for_status\n raise HTTPError(http_error_msg, response=self)\nrequests.exceptions.HTTPError: 500 Server Error: INTERNAL SERVER ERROR for url: http://httpbin.org/status/500",
8+
"exception_name": "HTTPError"
9+
}

Diff for: tests/functional/test_logger.py

+14
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import functools
12
import inspect
23
import io
34
import json
45
import logging
56
import random
67
import re
78
import string
9+
import sys
810
import warnings
911
from ast import Dict
1012
from collections import namedtuple
@@ -892,3 +894,15 @@ def test_powertools_debug_env_var_warning(monkeypatch: pytest.MonkeyPatch):
892894
set_package_logger_handler()
893895
assert len(w) == 1
894896
assert str(w[0].message) == warning_message
897+
898+
899+
def test_logger_log_uncaught_exceptions(service_name, stdout):
900+
# GIVEN an initialized Logger is set with log_uncaught_exceptions
901+
logger = Logger(service=service_name, stream=stdout, log_uncaught_exceptions=True)
902+
903+
# WHEN Python's exception hook is inspected
904+
exception_hook = sys.excepthook
905+
906+
# THEN it should contain our custom exception hook with a copy of our logger
907+
assert isinstance(exception_hook, functools.partial)
908+
assert exception_hook.keywords.get("logger") == logger

0 commit comments

Comments
 (0)