We use Sentry on nearly every project. Install the SDK, configure a DSN, trust that data shows up. For typical web apps, that trust is well-placed. But if your app does heavy background processing, you might be flying blind without knowing it.

The symptom

I have worked on a project where the application runs a calculation pipeline, where each step is wrapped in its own Sentry transaction. Some steps process thousands of records in batches, each batch firing a handful of queries (including eager-loaded relationships). That adds up to well over a thousand SQL queries per step. These were the steps we most wanted to monitor.

They never showed up in Sentry. No error, no warning. Just a gap.

What's happening

The Sentry SDK auto-creates a child span for every SQLAlchemy query. With hundreds of queries per step, the transactions hit the SDK's default cap of 1,000 spans, each carrying the full SQL as its description. The serialised envelope exceeded Sentry's ingest size limit.

A second bug made it worse

A year and a half ago, I had already written a naive before_send_transaction hook that truncated long span descriptions. Transactions started coming in again. For a while. But the hook had an oversight: we later introduced spans with no description, which made it raise a TypeError.

Normally, you'd notice. But Sentry wraps this hook internally and silently swallows all exceptions. After the crash, the hook returns None, which the SDK interprets as "drop this transaction on purpose."

One unnamed span and the entire transaction dropped. The transactions we lost were the ones we most wanted the data for.

The fix

A colleague came up with a better fix than the one I wrote.

Apart from fixing the TypeError, he observed that payload size is essentially the span count times the span size, so he capped both.

We now cap span count via the _experiments config:

sentry_sdk.init(
    dsn="...",
    _experiments={"max_spans": 500},
)

Then we truncate descriptions in a safer way in the before_send_transaction hook:

def shorten_overly_long_descriptions(event, hint):
    spans = event.get("spans")
    if not isinstance(spans, list):
        return event

    for span in spans:
        description = span.get("description")
        if not isinstance(description, str):
            span["description"] = ""
            continue

        encoded = description.encode("utf-8")
        if len(encoded) > 300:
            span["description"] = (
                encoded[:300].decode("utf-8", errors="ignore")
                + "..."
            )

    return event

Two notable changes:

  1. not isinstance(description, str) catches the None case that was silently killing transactions.
  2. Truncation is on UTF-8 bytes, not characters, since the ingest limit is on bytes.

Combined, worst-case payload drops to a bit over 300 KB (including span metadata and envelope overhead). My colleague verified that payloads up to 377 KB are accepted and added a regression test that constructs 500 max-length spans and asserts the serialised result stays under that limit.

When this affects you

Probably not for typical web requests. But check if you have background jobs, batch processing, or pipeline steps that generate hundreds of database queries under a single transaction scope. Look at your Sentry dashboard: are the heaviest jobs actually showing up?

The hardest part of this bug was realising something was missing from a dashboard where "nothing" looks exactly like "no data yet."