diff --git a/docs/changelog.md b/docs/changelog.md index 0b05b6c..4afb967 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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) @@ -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 diff --git a/src/pythonjsonlogger/defaults.py b/src/pythonjsonlogger/defaults.py index e70abda..7ba2fbf 100644 --- a/src/pythonjsonlogger/defaults.py +++ b/src/pythonjsonlogger/defaults.py @@ -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]: diff --git a/tests/test_formatters.py b/tests/test_formatters.py index d61ff6b..3b4ca59 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -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):