How to turn a Sentry issue title from ExceptionGroup: Download failed into DownloadError -> [HTTPDownloadError -> RuntimeError, HTTPStatusError].

The problem

Sentry displays issues with exception_type as the title and exception_value as the subtitle. With ExceptionGroup, this leads to identical titles for completely different root causes — the title is always just ExceptionGroup and the subtitle a generic message:

ExceptionGroup: Download failed
ExceptionGroup: Download failed
ExceptionGroup: Download failed
ExceptionGroup: Download failed

Four different Sentry issues, each with a different problem (DNS error, HTTP 403, HTTP 404, invalid URL), but indistinguishable at first glance.

The same problem occurs with exceptions wrapped in a generic envelope. This is a common pattern — the wrapper carries meta-information (retry count, endpoint URL, batch ID), but Sentry only shows the wrapper type:

DownloadError: Download failed
DownloadError: Download failed
DownloadError: Download failed

The actual causes are different every time — ConnectionTimeout, SSLCertVerificationError, HTTPStatusError(403) — but in the issues list we only see a generic DownloadError. We have to click into each event to find out what actually happened.

Or for example:

TaskError: Task processing failed
TaskError: Task processing failed

One is a JSONDecodeError, the other a PermissionError — but the title is identical.

The core of the problem: Both ExceptionGroup and exceptions with chains (__cause__/__context__) carry the real information in nested exceptions, not in the top-level message. Sentry cannot display this structure in the title.

Solution: SDK tag + server-side fingerprint rule

A combination of two mechanisms:

  • SDK (Python, before_send hook) — has access to the live exception object, can recursively traverse ExceptionGroup.exceptions and __cause__/__context__ chains. Writes the result as a tag.
  • Sentry server (fingerprint rule) — can override the title, but needs ready-made data. The tag from the SDK is available in the event.

Why it works

  1. before_send is called before sending the event to the server — the tag is part of the payload
  2. Server-side fingerprint rules are applied after receiving the event — they see tags from the SDK
  3. title= in a fingerprint rule is the only documented way to override an issue title

Why a tag and not another mechanism

  • A tag is a standard part of the Event payload — no hacking of TypedDict
  • It's visible in the Sentry UI (filtering, searching)
  • The fingerprint rule tags.* matcher is documented and supported
  • Clean separation: the backend provides data, the server displays it

Implementation

Exception mixin

class SentryTitleMixin:
    """Mixin for exceptions with informative Sentry titles."""

    @staticmethod
    def _format_sentry_title(exc: BaseException) -> str:
        name = type(exc).__name__
        if isinstance(exc, BaseExceptionGroup):
            inner = ", ".join(
                SentryTitleMixin._format_sentry_title(e) for e in exc.exceptions
            )
            return f"{name} -> [{inner}]"
        cause = exc.__cause__ or exc.__context__
        if cause:
            return f"{name} -> {SentryTitleMixin._format_sentry_title(cause)}"
        return name

    def sentry_title(self) -> str:
        """Return a human-readable title describing the exception chain."""
        assert isinstance(self, BaseException)
        return self._format_sentry_title(self)

_format_sentry_title() is a @staticmethod because it recursively traverses nested exceptions that don't have SentryTitleMixin themselves — it needs to accept any BaseException, not just self.

The recursive _format_sentry_title() builds a hierarchical description:

  • ExceptionGroupName -> [Sub1, Sub2]
  • Chained exception (__cause__/__context__) → Name -> CauseName
  • CombinationDownloadError -> [HTTPDownloadError -> RuntimeError, HTTPStatusError]

Exception with mixin

class DownloadError(ExceptionGroup, SentryTitleMixin):
    """Download failed in all attempts."""

No __new__, no derive() — the mixin is opt-in and only added to exceptions where it makes sense. It only affects Sentry display, no changes to exception behavior.

before_send hook

def before_send(event, hint):
    # ... existing filters ...

    # SentryTitleMixin — set tag for server-side fingerprint rules.
    #   Requires a fingerprint rule in Sentry (Project Settings → Issue Grouping):
    #   tags.sentry_title:* -> {{ default }} title="{{ tags.sentry_title }}"
    exc_info = hint.get("exc_info") if hint else None
    if exc_info and len(exc_info) >= 2:
        exc = exc_info[1]
        if isinstance(exc, SentryTitleMixin):
            event.setdefault("tags", {})["sentry_title"] = exc.sentry_title()

    return event

Fingerprint rule in Sentry UI

Project Settings → Issue Grouping → Fingerprint Rules:

tags.sentry_title:* -> {{ default }} title="{{ tags.sentry_title }}"

{{ default }} preserves the default grouping — the rule only changes the displayed title.

Result

Instead of:

ExceptionGroup: Download failed

Sentry displays:

DownloadError -> [HTTPDownloadError -> RuntimeError, HTTPStatusError]

Broader observations

  • The Sentry SDK and server have asymmetric capabilities: the SDK sees runtime objects, the server only sees the serialized payload. But only the server can override the title (via fingerprint rules).
  • ExceptionGroup (Python 3.11+) is still a second-class citizen in Sentry — the title only shows the top-level message, not the sub-exception structure.
  • Fingerprint rules are primarily designed for grouping, but the title= attribute makes them the only way to affect how an issue appears in the list.
  • The pattern "SDK prepares data as a tag, server consumes it in a rule" is generally applicable to other scenarios where you need runtime information in server-side configuration.