Context manager, který potlačí duplicitní Sentry log-eventy a pošle jednu výjimku s plným kontextem — informativní logy v aplikaci, čistý grouping v Sentry.

Problém

Sentry Python SDK s LoggingIntegration zachytává každý logger.error() / logger.warning() jako samostatný Sentry event. To přináší dva problémy najednou.

Duplikáty. Když chybu ošetříte a zalogujete, dostanete v Sentry dva eventy: log-event + exception event (pokud voláte i capture_exception). Jeden caught error vygeneruje 2–3 Sentry eventy, každý s jiným groupingem, a kvóta se plní zbytečně rychle.

Rozbitý grouping. Sentry u log-eventů vidí jen text zprávy. Nemá výjimku, nemá stacktrace — nemá se čeho chytit. Zprávy Download failed: url=AAA a Download failed: url=BBB jsou pro Sentry dva různé problémy, i když jde o tutéž chybu se stejným stacktrace. Naivní řešení je nedávat specifické informace do log message — ale to je přesně to, co v logu chceme. Informativní zpráva Download failed: url=https://example.com/api/v2/products je v logu neocenitelná, ale pro Sentry grouping je jed.

Co context manager dělá

with sentry_suppress_logs_capture_exception(exc):
    logger.warning("Download failed: url=%s", url)

Uvnitř bloku:

  • logger.warning()/logger.error() jde jen do lokálního logu — Sentry log-event se zahodí (přes before_send hook)
  • Můžete logovat kolik chcete, žádný z logů nevytvoří Sentry event

Po ukončení bloku:

  • Sentry dostane přesně 1 event: capture_exception(exc) s úrovní warning
  • Event obsahuje plný stacktrace, exception chain, tagy a context

Jak to funguje uvnitř

Tři spolupracující komponenty:

1. Context variable

Signalizuje, že jsme uvnitř suppress bloku:

_suppress_ctx: contextvars.ContextVar[bool] = contextvars.ContextVar(
    "sentry_suppress", default=False
)

2. Log record factory hook

Označí log záznamy z potlačeného bloku:

def factory(*args, **kwargs):
    record = original_factory(*args, **kwargs)
    if record.levelno >= logging.WARNING and _suppress_ctx.get():
        record.sentry_skip = True
    return record

3. before_send hook

Zahodí označené log-eventy:

def before_send(event, hint):
    record = hint.get("log_record")
    if record is not None and getattr(record, "sentry_skip", False):
        return None
    return event

Context manager samotný

@contextmanager
def sentry_suppress_logs_capture_exception(
    exc: BaseException,
    *,
    tags: Mapping[str, str] | None = None,
    context: Mapping[str, Any] | None = None,
) -> Iterator[None]:
    token = _suppress_ctx.set(True)
    try:
        yield
    finally:
        _suppress_ctx.reset(token)

        with sentry_sdk.new_scope() as scope:
            scope.set_level("warning")

            if tags:
                for k, v in tags.items():
                    scope.set_tag(k, v)

            scope.set_extra("exc_type", type(exc).__name__)
            scope.set_extra("exc_message", str(exc))

            if context:
                scope.set_context("warn.context", dict(context))

            scope.capture_exception(exc)

Typické použití

Caught error, který nechceme propagovat, ale chceme ho vidět v Sentry:

try:
    page = await downloader.download(url=url)
except HTTPError as exc:
    with sentry_suppress_logs_capture_exception(exc):
        logger.warning("Download failed: url=%s", url)
    page = DownloadPage(url=url, content="")  # fallback

Ošetření chyby v error handleru — nechceme, aby sekundární selhání přebilo primární:

try:
    task = await repository.find(id=task_id)
except Exception as refetch_err:
    with sentry_suppress_logs_capture_exception(refetch_err):
        logger.warning("Failed to re-fetch task %s: %s", task_id, refetch_err)
    task = None

Race condition — objekt smazán jiným procesem:

except StaleDataError as exc:
    with sentry_suppress_logs_capture_exception(exc):
        logger.warning(
            "Product %s deleted before update could commit, skipping.",
            product_id,
        )

Bez vs. s context managerem

Bez S
logger.error() → 1 Sentry log-event (bez stacktrace) logger.warning() → jen lokální log
capture_exception() → 1 Sentry exception event capture_exception() → 1 Sentry exception event
2 eventy v Sentry, duplikátní šum 1 event v Sentry, plný kontext

Proč contextvars a ne thread-local

Aplikace je async (asyncio). Thread-local by nefungoval — coroutiny sdílejí vlákno. contextvars.ContextVar je asyncio-aware a správně izoluje kontext per-task.