Repository: hynek/structlog

Issue:
`ExceptionDictTransformer` hides `ExceptionGroup` sub exceptions
structlog version `24.4.0`

The `ExceptionDictTransformer` hides the underlying `ExceptionGroup#exceptions` values.

For example, the following `ExceptionGroup`:
```
ExceptionGroup("some errors", [ValueError("wrong value"), TypeError("wrong type")])
```
will be returned as:
```
[{'exc_type': 'ExceptionGroup', 'exc_value': 'some errors (2 sub-exceptions)', 'syntax_error': None, 'is_cause': False, 'frames': []}]
```

I would have expected it to recursively iterate through the sub exceptions and add them as a normal top level exception would be transformed.

Maybe I'm missing something here and there is a built in transformer for this?


Discussion:
Here is a minimal example:

```python
import asyncio
import structlog


structlog.configure(
    processors=[
        structlog.processors.dict_tracebacks,
        structlog.processors.JSONRenderer(),
    ],
)
log = structlog.get_logger()


async def task(i: int) -> None:
    raise ValueError(i)
    await asyncio.sleep(1)


async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(task(0))
        tg.create_task(task(1))


try:
    asyncio.run(main())
except Exception as e:
    log.exception("onoes", exc_info=e)

```

The Rich traceback of the ConsoleRenderer looks similar though, so the `ExceptionDictTransformer` behaves "as expected".

I suggest to wait until https://github.com/Textualize/rich/issues/1859 is done and than steal their code as we did before. ;-)
Rich supports exception groups since [14.0.0](https://github.com/Textualize/rich/blob/master/CHANGELOG.md#1400---2025-03-30).  I will see if I find the time to port these changes to structlog next week.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0125f807..87601113 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,12 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
 - Support for Python 3.14 and Python 3.13.4.
   [#723](https://github.com/hynek/structlog/pull/723)
 
+- `structlog.tracebacks` now handles [exception groups](https://docs.python.org/3/library/exceptions.html#exception-groups).
+  `structlog.tracebacks.Stack` has two new fields, `is_group: bool` and `exceptions: list[Trace]`.
+  This works similarly to what Rich v14.0.0 does.
+  [#720](https://github.com/hynek/structlog/pull/720)
+
+
 ### Fixed
 
 - `structlog.processors.ExceptionPrettyPrinter` now respects the *exception_formatter* arguments instead of always using the default formatter.
diff --git a/docs/api.rst b/docs/api.rst
index 3719756a..f13fdbb9 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -197,7 +197,7 @@ API Reference
          ...     1 / 0
          ... except ZeroDivisionError:
          ...     log.exception("Cannot compute!")
-         {"event": "Cannot compute!", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "<doctest default[3]>", "lineno": 2, "name": "<module>", "locals": {..., "var": "'spam'"}}]}]}
+         {"event": "Cannot compute!", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "<doctest default[3]>", "lineno": 2, "name": "<module>", "locals": {..., "var": "'spam'"}}], "is_group": false, "exceptions": []}]}
 
 .. autoclass:: KeyValueRenderer
 
diff --git a/src/structlog/tracebacks.py b/src/structlog/tracebacks.py
index a6c6cefc..949df9cd 100644
--- a/src/structlog/tracebacks.py
+++ b/src/structlog/tracebacks.py
@@ -16,6 +16,7 @@
 
 import os
 import os.path
+import sys
 
 from dataclasses import asdict, dataclass, field
 from traceback import walk_tb
@@ -84,6 +85,9 @@ class Stack:
 
     .. versionchanged:: 25.2.0
        Added the *exc_notes* field.
+
+    .. versionchanged:: 25.4.0
+       Added the *is_group* and *exceptions* fields.
     """
 
     exc_type: str
@@ -92,6 +96,8 @@ class Stack:
     syntax_error: SyntaxError_ | None = None
     is_cause: bool = False
     frames: list[Frame] = field(default_factory=list)
+    is_group: bool = False
+    exceptions: list[Trace] = field(default_factory=list)
 
 
 @dataclass
@@ -222,9 +228,13 @@ def extract(
         A Trace instance with structured information about all exceptions.
 
     .. versionadded:: 22.1.0
+
     .. versionchanged:: 24.3.0
        Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*
        and *use_rich* arguments.
+
+    .. versionchanged:: 25.4.0
+       Handle exception groups.
     """
 
     stacks: list[Stack] = []
@@ -240,6 +250,24 @@ def extract(
             is_cause=is_cause,
         )
 
+        if sys.version_info >= (3, 11):
+            if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)):  # noqa: F821
+                stack.is_group = True
+                for exception in exc_value.exceptions:
+                    stack.exceptions.append(
+                        extract(
+                            type(exception),
+                            exception,
+                            exception.__traceback__,
+                            show_locals=show_locals,
+                            locals_max_length=locals_max_length,
+                            locals_max_string=locals_max_string,
+                            locals_hide_dunder=locals_hide_dunder,
+                            locals_hide_sunder=locals_hide_sunder,
+                            use_rich=use_rich,
+                        )
+                    )
+
         if isinstance(exc_value, SyntaxError):
             stack.syntax_error = SyntaxError_(
                 offset=exc_value.offset or 0,
@@ -377,6 +405,9 @@ class ExceptionDictTransformer:
     .. versionchanged:: 25.1.0
        *locals_max_length* and *locals_max_string* may be None to disable
        truncation.
+
+    .. versionchanged:: 25.4.0
+       Handle exception groups.
     """
 
     def __init__(
@@ -451,13 +482,21 @@ def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
                 *stack.frames[-half:],
             ]
 
-        stacks = [asdict(stack) for stack in trace.stacks]
-        for stack_dict in stacks:
+        return self._as_dict(trace)
+
+    def _as_dict(self, trace: Trace) -> list[dict[str, Any]]:
+        stack_dicts = []
+        for stack in trace.stacks:
+            stack_dict = asdict(stack)
             for frame_dict in stack_dict["frames"]:
                 if frame_dict["locals"] is None or any(
                     frame_dict["filename"].startswith(path)
                     for path in self.suppress
                 ):
                     del frame_dict["locals"]
-
-        return stacks
+            if stack.is_group:
+                stack_dict["exceptions"] = [
+                    self._as_dict(t) for t in stack.exceptions
+                ]
+            stack_dicts.append(stack_dict)
+        return stack_dicts


Test patch:
diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py
index 0dd1eb80..f83f72fb 100644
--- a/tests/test_tracebacks.py
+++ b/tests/test_tracebacks.py
@@ -5,6 +5,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import inspect
 import json
 import sys
@@ -567,6 +568,84 @@ def bar(n):
     ]
 
 
+@pytest.mark.skipif(
+    sys.version_info < (3, 11), reason="Requires Python 3.11 or higher"
+)
+def test_exception_groups() -> None:
+    """
+    Exception groups are detected and a list of Trace instances is added to
+    the exception group's Trace.
+    """
+    lineno = get_next_lineno()
+
+    async def t1() -> None:
+        1 / 0
+
+    async def t2() -> None:
+        raise ValueError("Blam!")
+
+    async def main():
+        async with asyncio.TaskGroup() as tg:
+            tg.create_task(t1())
+            tg.create_task(t2())
+
+    try:
+        asyncio.run(main())
+    except Exception as e:
+        trace = tracebacks.extract(type(e), e, e.__traceback__)
+
+    assert "ExceptionGroup" == trace.stacks[0].exc_type
+    assert (
+        "unhandled errors in a TaskGroup (2 sub-exceptions)"
+        == trace.stacks[0].exc_value
+    )
+    exceptions = trace.stacks[0].exceptions
+    assert [
+        tracebacks.Trace(
+            stacks=[
+                tracebacks.Stack(
+                    exc_type="ZeroDivisionError",
+                    exc_value="division by zero",
+                    exc_notes=[],
+                    syntax_error=None,
+                    is_cause=False,
+                    frames=[
+                        tracebacks.Frame(
+                            filename=__file__,
+                            lineno=lineno + 2,
+                            name="t1",
+                            locals=None,
+                        )
+                    ],
+                    is_group=False,
+                    exceptions=[],
+                )
+            ]
+        ),
+        tracebacks.Trace(
+            stacks=[
+                tracebacks.Stack(
+                    exc_type="ValueError",
+                    exc_value="Blam!",
+                    exc_notes=[],
+                    syntax_error=None,
+                    is_cause=False,
+                    frames=[
+                        tracebacks.Frame(
+                            filename=__file__,
+                            lineno=lineno + 5,
+                            name="t2",
+                            locals=None,
+                        )
+                    ],
+                    is_group=False,
+                    exceptions=[],
+                )
+            ]
+        ),
+    ] == exceptions
+
+
 @pytest.mark.parametrize(
     ("kwargs", "local_variable"),
     [
@@ -623,6 +702,7 @@ def test_json_traceback():
             "exc_type": "ZeroDivisionError",
             "exc_value": "division by zero",
             "exc_notes": [],
+            "exceptions": [],
             "frames": [
                 {
                     "filename": __file__,
@@ -631,6 +711,7 @@ def test_json_traceback():
                 }
             ],
             "is_cause": False,
+            "is_group": False,
             "syntax_error": None,
         },
     ] == result
@@ -657,6 +738,7 @@ def test_json_traceback_with_notes():
             "exc_type": "ZeroDivisionError",
             "exc_value": "division by zero",
             "exc_notes": ["This is a note.", "This is another note."],
+            "exceptions": [],
             "frames": [
                 {
                     "filename": __file__,
@@ -665,6 +747,7 @@ def test_json_traceback_with_notes():
                 }
             ],
             "is_cause": False,
+            "is_group": False,
             "syntax_error": None,
         },
     ] == result
@@ -687,6 +770,7 @@ def test_json_traceback_locals_max_string():
             "exc_type": "ZeroDivisionError",
             "exc_value": "division by zero",
             "exc_notes": [],
+            "exceptions": [],
             "frames": [
                 {
                     "filename": __file__,
@@ -700,6 +784,7 @@ def test_json_traceback_locals_max_string():
                 }
             ],
             "is_cause": False,
+            "is_group": False,
             "syntax_error": None,
         },
     ] == result
@@ -844,6 +929,76 @@ def test_json_traceback_value_error(
         tracebacks.ExceptionDictTransformer(**kwargs)
 
 
+@pytest.mark.skipif(
+    sys.version_info < (3, 11), reason="Requires Python 3.11 or higher"
+)
+def test_json_exception_groups() -> None:
+    """
+    When rendered as JSON, the "Trace.stacks" is stripped, so "exceptions" is a
+    list of lists and not a list of objects (with a single "stacks" attribute.
+    """
+
+    lineno = get_next_lineno()
+
+    async def t1() -> None:
+        1 / 0
+
+    async def t2() -> None:
+        raise ValueError("Blam!")
+
+    async def main():
+        async with asyncio.TaskGroup() as tg:
+            tg.create_task(t1())
+            tg.create_task(t2())
+
+    try:
+        asyncio.run(main())
+    except Exception as e:
+        format_json = tracebacks.ExceptionDictTransformer(show_locals=False)
+        result = format_json((type(e), e, e.__traceback__))
+
+    assert "ExceptionGroup" == result[0]["exc_type"]
+    exceptions = result[0]["exceptions"]
+    assert [
+        [
+            {
+                "exc_type": "ZeroDivisionError",
+                "exc_value": "division by zero",
+                "exc_notes": [],
+                "syntax_error": None,
+                "is_cause": False,
+                "frames": [
+                    {
+                        "filename": __file__,
+                        "lineno": lineno + 2,
+                        "name": "t1",
+                    }
+                ],
+                "is_group": False,
+                "exceptions": [],
+            }
+        ],
+        [
+            {
+                "exc_type": "ValueError",
+                "exc_value": "Blam!",
+                "exc_notes": [],
+                "syntax_error": None,
+                "is_cause": False,
+                "frames": [
+                    {
+                        "filename": __file__,
+                        "lineno": lineno + 5,
+                        "name": "t2",
+                    }
+                ],
+                "is_group": False,
+                "exceptions": [],
+            }
+        ],
+    ] == exceptions
+
+
 class TestLogException:
     """
     Higher level integration tests for `Logger.exception()`.