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_sendhook) — has access to the live exception object, can recursively traverseExceptionGroup.exceptionsand__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
before_sendis called before sending the event to the server — the tag is part of the payload- Server-side fingerprint rules are applied after receiving the event — they see tags from the SDK
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:
- ExceptionGroup →
Name -> [Sub1, Sub2] - Chained exception (
__cause__/__context__) →Name -> CauseName - Combination →
DownloadError -> [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.