Skip to content

Commit e555df1

Browse files
feat(logger): new stack_trace field with rich exception details (#3147)
Co-authored-by: Leandro Damascena <[email protected]>
1 parent a589e25 commit e555df1

File tree

10 files changed

+190
-1
lines changed

10 files changed

+190
-1
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ dmypy.json
252252
.pyre/
253253

254254
### VisualStudioCode ###
255+
.vscode
255256
.vscode/*
256257
!.vscode/tasks.json
257258
!.vscode/launch.json

aws_lambda_powertools/logging/formatter.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
import logging
66
import os
77
import time
8+
import traceback
89
from abc import ABCMeta, abstractmethod
910
from datetime import datetime, timezone
1011
from functools import partial
1112
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
1213

13-
from aws_lambda_powertools.logging.types import LogRecord
14+
from aws_lambda_powertools.logging.types import LogRecord, LogStackTrace
1415
from aws_lambda_powertools.shared import constants
1516
from aws_lambda_powertools.shared.functions import powertools_dev_is_set
1617

@@ -77,6 +78,7 @@ def __init__(
7778
log_record_order: List[str] | None = None,
7879
utc: bool = False,
7980
use_rfc3339: bool = False,
81+
serialize_stacktrace: bool = True,
8082
**kwargs,
8183
) -> None:
8284
"""Return a LambdaPowertoolsFormatter instance.
@@ -148,6 +150,8 @@ def __init__(
148150
self.keys_combined = {**self._build_default_keys(), **kwargs}
149151
self.log_format.update(**self.keys_combined)
150152

153+
self.serialize_stacktrace = serialize_stacktrace
154+
151155
super().__init__(datefmt=self.datefmt)
152156

153157
def serialize(self, log: LogRecord) -> str:
@@ -158,11 +162,15 @@ def format(self, record: logging.LogRecord) -> str: # noqa: A003
158162
"""Format logging record as structured JSON str"""
159163
formatted_log = self._extract_log_keys(log_record=record)
160164
formatted_log["message"] = self._extract_log_message(log_record=record)
165+
161166
# exception and exception_name fields can be added as extra key
162167
# in any log level, we try to extract and use them first
163168
extracted_exception, extracted_exception_name = self._extract_log_exception(log_record=record)
164169
formatted_log["exception"] = formatted_log.get("exception", extracted_exception)
165170
formatted_log["exception_name"] = formatted_log.get("exception_name", extracted_exception_name)
171+
if self.serialize_stacktrace:
172+
# Generate the traceback from the traceback library
173+
formatted_log["stack_trace"] = self._serialize_stacktrace(log_record=record)
166174
formatted_log["xray_trace_id"] = self._get_latest_trace_id()
167175
formatted_log = self._strip_none_records(records=formatted_log)
168176

@@ -273,6 +281,24 @@ def _extract_log_message(self, log_record: logging.LogRecord) -> Union[Dict[str,
273281

274282
return message
275283

284+
def _serialize_stacktrace(self, log_record: logging.LogRecord) -> LogStackTrace | None:
285+
if log_record.exc_info:
286+
exception_info: LogStackTrace = {
287+
"type": log_record.exc_info[0].__name__, # type: ignore
288+
"value": log_record.exc_info[1], # type: ignore
289+
"module": log_record.exc_info[1].__class__.__module__,
290+
"frames": [],
291+
}
292+
293+
exception_info["frames"] = [
294+
{"file": fs.filename, "line": fs.lineno, "function": fs.name, "statement": fs.line}
295+
for fs in traceback.extract_tb(log_record.exc_info[2])
296+
]
297+
298+
return exception_info
299+
300+
return None
301+
276302
def _extract_log_exception(self, log_record: logging.LogRecord) -> Union[Tuple[str, str], Tuple[None, None]]:
277303
"""Format traceback information, if available
278304

aws_lambda_powertools/logging/logger.py

+2
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ def __init__(
220220
log_record_order: Optional[List[str]] = None,
221221
utc: bool = False,
222222
use_rfc3339: bool = False,
223+
serialize_stacktrace: bool = True,
223224
**kwargs,
224225
) -> None:
225226
self.service = resolve_env_var_choice(
@@ -253,6 +254,7 @@ def __init__(
253254
"log_record_order": log_record_order,
254255
"utc": utc,
255256
"use_rfc3339": use_rfc3339,
257+
"serialize_stacktrace": serialize_stacktrace,
256258
}
257259

258260
self._init_logger(formatter_options=formatter_options, log_level=level, **kwargs)

aws_lambda_powertools/logging/types.py

+9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from aws_lambda_powertools.shared.types import NotRequired, TypeAlias, TypedDict
66

77
LogRecord: TypeAlias = Union[Dict[str, Any], "PowertoolsLogRecord"]
8+
LogStackTrace: TypeAlias = Union[Dict[str, Any], "PowertoolsStackTrace"]
89

910

1011
class PowertoolsLogRecord(TypedDict):
@@ -33,3 +34,11 @@ class PowertoolsLogRecord(TypedDict):
3334
# Fields from logger.exception
3435
exception_name: NotRequired[str]
3536
exception: NotRequired[str]
37+
stack_trace: NotRequired[Dict[str, Any]]
38+
39+
40+
class PowertoolsStackTrace(TypedDict):
41+
type: str
42+
value: str
43+
module: str
44+
frames: List[Dict[str, Any]]

docs/contributing/documentation/rfcs.md

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: Writing Request For Comment (RFC)
33
description: Contributing RFCs to Powertools for AWS Lambda (Python)
44
---
55

6+
<!-- markdownlint-disable MD043 -->
7+
68
## TBW
79

810
Something great will come.

docs/core/logger.md

+16
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,22 @@ Logger can optionally log uncaught exceptions by setting `log_uncaught_exception
319319
--8<-- "examples/logger/src/logging_uncaught_exceptions_output.json"
320320
```
321321

322+
#### Stack trace logging
323+
324+
By default, the Logger will automatically include the full stack trace in JSON format when using `logger.exception`. If you want to disable this feature, set `serialize_stacktrace=False` during initialization."
325+
326+
=== "logging_stacktrace.py"
327+
328+
```python hl_lines="7 15"
329+
--8<-- "examples/logger/src/logging_stacktrace.py"
330+
```
331+
332+
=== "logging_stacktrace_output.json"
333+
334+
```json hl_lines="9-27"
335+
--8<-- "examples/logger/src/logging_stacktrace_output.json"
336+
```
337+
322338
### Date formatting
323339

324340
Logger uses Python's standard logging date format with the addition of timezone: `2021-05-03 11:47:12,494+0200`.
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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(serialize_stacktrace=True)
8+
9+
10+
def lambda_handler(event: dict, context: LambdaContext) -> str:
11+
try:
12+
ret = requests.get(ENDPOINT)
13+
ret.raise_for_status()
14+
except requests.HTTPError as e:
15+
logger.exception(e)
16+
raise RuntimeError("Unable to fullfil request") from e
17+
18+
return "hello world"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"level":"ERROR",
3+
"location":"lambda_handler:16",
4+
"message":"500 Server Error: INTERNAL SERVER ERROR for url: http://httpbin.org/status/500",
5+
"timestamp":"2023-10-09 17:47:50,191+0000",
6+
"service":"service_undefined",
7+
"exception":"Traceback (most recent call last):\n File \"/var/task/app.py\", line 14, in lambda_handler\n ret.raise_for_status()\n File \"/var/task/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+
"stack_trace":{
10+
"type":"HTTPError",
11+
"value":"500 Server Error: INTERNAL SERVER ERROR for url: http://httpbin.org/status/500",
12+
"module":"requests.exceptions",
13+
"frames":[
14+
{
15+
"file":"/var/task/app.py",
16+
"line":14,
17+
"function":"lambda_handler",
18+
"statement":"ret.raise_for_status()"
19+
},
20+
{
21+
"file":"/var/task/requests/models.py",
22+
"line":1021,
23+
"function":"raise_for_status",
24+
"statement":"raise HTTPError(http_error_msg, response=self)"
25+
}
26+
]
27+
}
28+
}

tests/functional/test_logger.py

+34
Original file line numberDiff line numberDiff line change
@@ -959,3 +959,37 @@ def test_stream_defaults_to_stdout(service_name, capsys):
959959
# NOTE: we can't assert on capsys.readouterr().err due to a known bug: https://github.com/pytest-dev/pytest/issues/5997
960960
log = json.loads(capsys.readouterr().out.strip())
961961
assert log["message"] == msg
962+
963+
964+
def test_logger_logs_stack_trace_with_default_value(service_name, stdout):
965+
# GIVEN a Logger instance with serialize_stacktrace default value = True
966+
logger = Logger(service=service_name, stream=stdout)
967+
968+
# WHEN invoking a Lambda
969+
def handler(event, context):
970+
try:
971+
raise ValueError("something went wrong")
972+
except Exception:
973+
logger.exception("Received an exception")
974+
975+
# THEN we expect a "stack_trace" in log
976+
handler({}, lambda_context)
977+
log = capture_logging_output(stdout)
978+
assert "stack_trace" in log
979+
980+
981+
def test_logger_logs_stack_trace_with_non_default_value(service_name, stdout):
982+
# GIVEN a Logger instance with serialize_stacktrace = False
983+
logger = Logger(service=service_name, stream=stdout, serialize_stacktrace=False)
984+
985+
# WHEN invoking a Lambda
986+
def handler(event, context):
987+
try:
988+
raise ValueError("something went wrong")
989+
except Exception:
990+
logger.exception("Received an exception")
991+
992+
# THEN we expect a "stack_trace" not in log
993+
handler({}, lambda_context)
994+
log = capture_logging_output(stdout)
995+
assert "stack_trace" not in log

tests/functional/test_logger_powertools_formatter.py

+53
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,27 @@
66
import re
77
import string
88
import time
9+
from collections import namedtuple
910

1011
import pytest
1112

1213
from aws_lambda_powertools import Logger
14+
from aws_lambda_powertools.logging.formatter import LambdaPowertoolsFormatter
1315
from aws_lambda_powertools.logging.formatters.datadog import DatadogLogFormatter
1416

1517

18+
@pytest.fixture
19+
def lambda_context():
20+
lambda_context = {
21+
"function_name": "test",
22+
"memory_limit_in_mb": 128,
23+
"invoked_function_arn": "arn:aws:lambda:eu-west-1:809313241:function:test",
24+
"aws_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72",
25+
}
26+
27+
return namedtuple("LambdaContext", lambda_context.keys())(*lambda_context.values())
28+
29+
1630
@pytest.fixture
1731
def stdout():
1832
return io.StringIO()
@@ -350,3 +364,42 @@ def test_datadog_formatter_use_rfc3339_date(stdout, service_name):
350364
log = capture_logging_output(stdout)
351365

352366
assert re.fullmatch(RFC3339_REGEX, log["timestamp"]) # "2022-10-27T17:42:26.841+0200"
367+
368+
369+
def test_logger_logs_stack_trace_with_formatter_default_value(service_name, stdout):
370+
# GIVEN a Logger instance with LambdaPowertoolsFormatter set explictly
371+
# GIVE serialize_stacktrace default value = True
372+
logger = Logger(service=service_name, stream=stdout, logger_formatter=LambdaPowertoolsFormatter())
373+
374+
# WHEN invoking a Lambda
375+
def handler(event, context):
376+
try:
377+
raise ValueError("something went wrong")
378+
except Exception:
379+
logger.exception("Received an exception")
380+
381+
# THEN we expect a "stack_trace" in log
382+
handler({}, lambda_context)
383+
log = capture_logging_output(stdout)
384+
assert "stack_trace" in log
385+
386+
387+
def test_logger_logs_stack_trace_with_formatter_non_default_value(service_name, stdout):
388+
# GIVEN a Logger instance with serialize_stacktrace = False
389+
logger = Logger(
390+
service=service_name,
391+
stream=stdout,
392+
logger_formatter=LambdaPowertoolsFormatter(serialize_stacktrace=False),
393+
)
394+
395+
# WHEN invoking a Lambda
396+
def handler(event, context):
397+
try:
398+
raise ValueError("something went wrong")
399+
except Exception:
400+
logger.exception("Received an exception")
401+
402+
# THEN we expect a "stack_trace" not in log
403+
handler({}, lambda_context)
404+
log = capture_logging_output(stdout)
405+
assert "stack_trace" not in log

0 commit comments

Comments
 (0)