Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Documentation: Quickstart examples are tested to ensure the sample output matches reality.
- Add support for Python 3.15, PyPy 3.12

### Changed
- Preserve an exception's own notes when encoding exception instances, including exception group summaries. Related to [#67](https://github.com/nhairs/python-json-logger/issues/67).

### Removed
- `STYLE_STRING_FORMAT_REGEX`, which is no longer used to find `{` style fields. [#75](https://github.com/nhairs/python-json-logger/pull/75)

Expand All @@ -24,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `defaults` is copied on construction like `rename_fields` and `static_fields`, so DictConfigurator `ext://` prefixes are converted and mutating the caller's dict cannot change formatter state.
- A string passed as `reserved_attrs` raises `TypeError` instead of being iterated as characters; use a list or tuple of field names.

Thanks @dylanpulver, @Gonghan-Princess, @JackyHsu-X, @00200200
Thanks @dylanpulver, @Gonghan-Princess, @JackyHsu-X, @00200200, @SAY-5

## [4.2.0](https://github.com/nhairs/python-json-logger/compare/v4.1.0...v4.2.0) - 2026-08-15

Expand Down
10 changes: 9 additions & 1 deletion src/pythonjsonlogger/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,18 @@ def use_exception_default(obj: Any) -> TypeGuard[BaseException]:
def exception_default(obj: BaseException) -> str:
"""Default function for exception instances

Includes the exception's own notes, if present. Exception groups retain
their summary without recursively formatting their children.

Args:
obj: object to handle

*Changed in 4.2.1*: Include exception notes.
"""
return f"{obj.__class__.__name__}: {obj}"
result = f"{obj.__class__.__name__}: {obj}"
if hasattr(obj, "__notes__"):
return "\n".join([result, *obj.__notes__])
return result


def use_traceback_default(obj: Any) -> TypeGuard[TracebackType]:
Expand Down
94 changes: 94 additions & 0 deletions tests/test_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,100 @@ def test_common_types_encoded(
return


@pytest.mark.skipif(
sys.version_info < (3, 11), reason="Exception notes require Python 3.11 or higher"
)
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
@pytest.mark.parametrize(
["notes", "expected"],
[
([], "ValueError: my error"),
(["context"], "ValueError: my error\ncontext"),
([""], "ValueError: my error\n"),
(["first", "second"], "ValueError: my error\nfirst\nsecond"),
(["line 1\nline 2", "Привет"], "ValueError: my error\nline 1\nline 2\nПривет"),
],
)
def test_dict_exception_notes(
env: LoggingEnvironment,
class_: type[BaseJsonFormatter],
notes: list[str],
expected: str,
) -> None:
env.set_formatter(class_())
err = ValueError("my error")
for note in notes:
err.add_note(note)

env.logger.info({"error": err, "nested": {"error": err}, "errors": [err]})
log_json = env.load_json()

assert log_json["error"] == expected
assert log_json["nested"]["error"] == expected
assert log_json["errors"] == [expected]
return


@pytest.mark.skipif(
sys.version_info < (3, 11), reason="Exception notes require Python 3.11 or higher"
)
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
def test_dict_exception_note_subclass(
env: LoggingEnvironment, class_: type[BaseJsonFormatter]
) -> None:
class Note(str):
"""A string subclass accepted by add_note."""

err = ValueError("my error")
err.add_note(Note("context"))
env.set_formatter(class_())
env.logger.info({"error": err})

assert env.load_json()["error"] == "ValueError: my error\ncontext"
return


@pytest.mark.parametrize("class_", ALL_FORMATTERS)
def test_dict_exception_message_compatibility(
env: LoggingEnvironment, class_: type[BaseJsonFormatter]
) -> None:
class CustomError(Exception):
"""An exception with a custom formatted message."""

def __format__(self, format_spec: str) -> str:
return "custom message"

env.set_formatter(class_())
env.logger.info({"empty": ValueError(), "custom": CustomError("original message")})
log_json = env.load_json()

assert log_json["empty"] == "ValueError: "
assert log_json["custom"] == "CustomError: custom message"
return


@pytest.mark.skipif(
sys.version_info < (3, 11), reason="Exception groups and notes require Python 3.11 or higher"
)
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
@pytest.mark.parametrize("with_group_note", [False, True])
def test_dict_exception_group_notes(
env: LoggingEnvironment, class_: type[BaseJsonFormatter], with_group_note: bool
) -> None:
child = ValueError("child error")
child.add_note("child note")
group = ExceptionGroup("my group", [child])
expected = "ExceptionGroup: my group (1 sub-exception)"
if with_group_note:
group.add_note("group note")
expected += "\ngroup note"
env.set_formatter(class_())
env.logger.info({"error": group})

assert env.load_json()["error"] == expected
return


@pytest.mark.parametrize("class_", ALL_FORMATTERS)
def test_custom_default(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
def custom_default(obj):
Expand Down
Loading